From 3fe33b8647bced275796b2f6cb63c1876c202980 Mon Sep 17 00:00:00 2001 From: PurHur Date: Wed, 22 Jul 2026 06:34:30 +0000 Subject: [PATCH] Stdlib: DOMNode::replaceChild accept Text/Comment children (#21976) replaceChild incorrectly required Element newChild, so createTextNode replacements threw Hierarchy request error while remove+append worked. Align with php-src tree-mutation child kinds and documentElement updates. Co-authored-by: Cursor --- ext/dom/VmDom.php | 50 ++++++++++++++++--- .../cases/dom/dom_node_replacechild_text.phpt | 37 ++++++++++++++ .../issue_21976_dom_replacechild_text.php | 38 ++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 test/compliance/cases/dom/dom_node_replacechild_text.phpt create mode 100644 test/repro/issue_21976_dom_replacechild_text.php diff --git a/ext/dom/VmDom.php b/ext/dom/VmDom.php index 93ba438cac8..3494ffa3e37 100644 --- a/ext/dom/VmDom.php +++ b/ext/dom/VmDom.php @@ -5762,13 +5762,44 @@ public static function replaceChild( ObjectEntry $oldChild ): ObjectEntry { self::assertMutationParent($parent); + self::assertChildOfParent($parent, $oldChild, 'DOMNode::replaceChild()'); + // php-src ext/dom/node.c dom_node_replace_child — DocumentFragment expands in place (#21976). if (self::isDocumentFragment($newChild)) { - throw new \DOMException('Hierarchy request error'); + $parentState = DomRegistry::state($parent); + $index = self::childIndex($parentState->childIds, $oldChild->id); + if (null === $index) { + throw new \DOMException('Not found error'); + } + $refChild = null; + if (isset($parentState->childIds[$index + 1])) { + $refChild = DomRegistry::entry($parentState->childIds[$index + 1]); + } + self::unregisterSubtreeElementIdsIfConnected($oldChild); + $parentState->childIds = \array_values(\array_filter( + $parentState->childIds, + static fn (int $id): bool => $id !== $oldChild->id + )); + self::linkChildToParent($oldChild, null); + if (self::isDocument($parent)) { + $docEl = $parent->getProperty(self::PROP_DOCUMENT_ELEMENT)->resolveIndirect(); + if (Variable::TYPE_OBJECT === $docEl->type && $docEl->toObject()->id === $oldChild->id) { + $parent->getProperty(self::PROP_DOCUMENT_ELEMENT)->null(); + $parentState->documentElementName = null; + } + } + self::insertFragmentChildrenBefore($ctx, $parent, $newChild, $refChild); + self::syncSubtree($ctx, $parent); + + return $oldChild; } - if (!self::isElement($newChild)) { - throw new \DOMException('Hierarchy request error'); + // Same child kinds as appendChild/insertBefore (Element, Text, CDATA, Comment, …). + // Previously Element-only — createTextNode replacements threw Hierarchy request error (#21976). + if (!self::isTreeMutationChild($newChild)) { + throw new \DOMException( + 'Hierarchy Request Error', + DomExceptionConstants::HIERARCHY_REQUEST_ERR + ); } - self::assertChildOfParent($parent, $oldChild, 'DOMNode::replaceChild()'); self::assertSameDocument($parent, $newChild); self::assertNotAncestorOfParent($parent, $newChild); self::unregisterSubtreeElementIdsIfConnected($oldChild); @@ -5782,8 +5813,15 @@ public static function replaceChild( self::linkChildToParent($oldChild, null); self::linkChildToParent($newChild, $parent); if (self::isDocument($parent)) { - $parent->getProperty(self::PROP_DOCUMENT_ELEMENT)->object($newChild); - $parentState->documentElementName = DomRegistry::state($newChild)->nodeName; + $docEl = $parent->getProperty(self::PROP_DOCUMENT_ELEMENT)->resolveIndirect(); + $oldWasDocEl = Variable::TYPE_OBJECT === $docEl->type && $docEl->toObject()->id === $oldChild->id; + if (self::isElement($newChild)) { + $parent->getProperty(self::PROP_DOCUMENT_ELEMENT)->object($newChild); + $parentState->documentElementName = DomRegistry::state($newChild)->nodeName; + } elseif ($oldWasDocEl) { + $parent->getProperty(self::PROP_DOCUMENT_ELEMENT)->null(); + $parentState->documentElementName = null; + } self::propagateDocumentId($newChild, $parent->id); } self::syncSubtree($ctx, $parent); diff --git a/test/compliance/cases/dom/dom_node_replacechild_text.phpt b/test/compliance/cases/dom/dom_node_replacechild_text.phpt new file mode 100644 index 00000000000..e564580f402 --- /dev/null +++ b/test/compliance/cases/dom/dom_node_replacechild_text.phpt @@ -0,0 +1,37 @@ +--TEST-- +DOMNode::replaceChild(createTextNode, textChild) updates textContent (#21976, ext/dom/node.c) +--FILE-- +loadHTML('

abcdef

'); +$p = $d->getElementsByTagName('p')->item(0); +$old = $p->firstChild; +$new = $d->createTextNode('ZZ'); +echo 'before=', $p->textContent, "\n"; +$p->replaceChild($new, $old); +echo 'after=', $p->textContent, "\n"; +echo 'old_parent=', ($old->parentNode === null ? 'null' : $old->parentNode->nodeName), "\n"; + +$d2 = new DOMDocument(); +@$d2->loadHTML('

abcdef

'); +$p2 = $d2->getElementsByTagName('p')->item(0); +$c = $d2->createComment('c'); +$p2->replaceChild($c, $p2->firstChild); +echo 'comment_len=', $p2->childNodes->length, ' tc=', $p2->textContent, "\n"; + +$d3 = new DOMDocument(); +@$d3->loadHTML('

x

'); +$p3 = $d3->getElementsByTagName('p')->item(0); +try { + $p3->replaceChild($d3->createAttribute('x'), $p3->firstChild); + echo "attr unexpected_ok\n"; +} catch (DOMException $e) { + echo 'attr ', $e->getMessage(), "\n"; +} +?> +--EXPECT-- +before=abcdef +after=ZZ +old_parent=null +comment_len=1 tc= +attr Hierarchy Request Error diff --git a/test/repro/issue_21976_dom_replacechild_text.php b/test/repro/issue_21976_dom_replacechild_text.php new file mode 100644 index 00000000000..1b1c3ef6f06 --- /dev/null +++ b/test/repro/issue_21976_dom_replacechild_text.php @@ -0,0 +1,38 @@ +loadHTML('

abcdef

'); +$p = $d->getElementsByTagName('p')->item(0); +$old = $p->firstChild; +$new = $d->createTextNode('ZZ'); +echo 'before=', $p->textContent, "\n"; +try { + $p->replaceChild($new, $old); + echo 'after=', $p->textContent, "\n"; +} catch (Throwable $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; +} + +// Workaround path (must remain green). +$d2 = new DOMDocument(); +@$d2->loadHTML('

abcdef

'); +$p2 = $d2->getElementsByTagName('p')->item(0); +$old2 = $p2->firstChild; +$new2 = $d2->createTextNode('YY'); +$p2->removeChild($old2); +$p2->appendChild($new2); +echo 'workaround=', $p2->textContent, "\n"; + +// Invalid: Attr is not a tree child (#21976 done-when). +$d3 = new DOMDocument(); +@$d3->loadHTML('

x

'); +$p3 = $d3->getElementsByTagName('p')->item(0); +try { + $p3->replaceChild($d3->createAttribute('x'), $p3->firstChild); + echo "attr unexpected_ok\n"; +} catch (Throwable $e) { + echo 'attr ', get_class($e), "\n"; +}