CVE-2026-41673
Description
xmldom is a pure JavaScript W3C standard-based (XML DOM Level 2 Core) DOMParser and XMLSerializer module. In @xmldom/xmldom prior to versions 0.9.10 and 0.8.13 and xmldom version 0.6.0 and prior, seven recursive traversals in lib/dom.js operate without a depth limit. A sufficiently deeply nested DOM tree causes a RangeError: Maximum call stack size exceeded, crashing the application. This issue has been patched in versions @xmldom/xmldom versions 0.9.10 and 0.8.13.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@xmldom/xmldomnpm | < 0.8.13 | 0.8.13 |
@xmldom/xmldomnpm | >= 0.9.0, < 0.9.10 | 0.9.10 |
xmldomnpm | <= 0.6.0 | — |
Affected products
1Patches
94845ef109221fix: prevent stack overflow in isEqualNode (GHSA-2v35-w6hq-6mfw)
4 files changed · +87 −43
docs/walk-dom.md+23 −0 modified@@ -117,3 +117,26 @@ current child list are never queued and therefore never visited. Neither Returning `walkDOM.STOP` from `enter` causes the function to return `STOP` immediately, discarding the rest of the stack. No further `enter` or `exit` calls are made — including any pending `EXIT` frames for ancestors. + +## When not to use `walkDOM` + +`walkDOM` visits a **single** tree and threads a single `context` value from +parent to children. This model breaks down when an algorithm must walk **two +trees in lockstep**, pairing corresponding nodes at each level. + +`Node.prototype.isEqualNode` is the canonical example. Equality requires +comparing `childNodes[0]` from tree A with `childNodes[0]` from tree B, +`childNodes[1]` with `childNodes[1]`, and so on — all the way down. There is +no way to encode "which node in the other tree corresponds to this one" as a +`context` value that `walkDOM` can thread, because `walkDOM` only knows about +one tree. + +Instead, `isEqualNode` maintains its own explicit stack of `{ node, other }` +pairs. The initial pair is `{ node: this, other: otherNode }`. For each pair +popped, the type-specific equality checks are applied and — if the child +counts match — each `{ node: childNodes[i], other: other.childNodes[i] }` pair +is pushed. Attribute nodes of each Element pair are pushed the same way: +`getAttributeNodeNS` looks up the matching attribute by namespace and local +name, and if none is found the comparison fails immediately. This produces +the same DFS order as the old recursive implementation without using the call +stack.
index.d.ts+6 −1 modified@@ -500,7 +500,12 @@ declare module '@xmldom/xmldom' { /** * Checks whether the given node is equal to this node. * - * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Node/isEqualNode) + * Two nodes are equal when they have the same type, defining characteristics (for the type), + * and the same `childNodes`. The comparison is iterative to avoid stack overflows on deeply + * nested trees. `Attribute` nodes of each `Element` pair are also compared iteratively. + * + * @see {@link https://dom.spec.whatwg.org/#concept-node-equals} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Node/isEqualNode} */ isEqualNode(other: Node): boolean;
lib/dom.js+53 −42 modified@@ -1141,57 +1141,68 @@ Node.prototype = { /** * Checks whether the given node is equal to this node. * + * Two nodes are equal when they have the same type, defining characteristics (for the type), + * and the same childNodes. The comparison is iterative to avoid stack overflows on + * deeply-nested trees. Attribute nodes of each Element pair are also pushed onto the stack + * and compared the same way. + * * @param {Node} [otherNode] + * @returns {boolean} * @see https://dom.spec.whatwg.org/#concept-node-equals + * @see ../docs/walk-dom.md. */ isEqualNode: function (otherNode) { if (!otherNode) return false; - if (this.nodeType !== otherNode.nodeType) return false; - - switch (this.nodeType) { - case this.DOCUMENT_TYPE_NODE: - if (this.name !== otherNode.name) return false; - if (this.publicId !== otherNode.publicId) return false; - if (this.systemId !== otherNode.systemId) return false; - break; - case this.ELEMENT_NODE: - if (this.namespaceURI !== otherNode.namespaceURI) return false; - if (this.prefix !== otherNode.prefix) return false; - if (this.localName !== otherNode.localName) return false; - if (this.attributes.length !== otherNode.attributes.length) return false; - for (var i = 0; i < this.attributes.length; i++) { - var attr = this.attributes.item(i); - if (!attr.isEqualNode(otherNode.getAttributeNodeNS(attr.namespaceURI, attr.localName))) { - return false; + // Use an explicit {node, other} pair stack to avoid call-stack overflow on deep trees. + // walkDOM cannot be used here — parallel two-tree traversal requires pairing + // corresponding nodes at each step across both trees simultaneously. + var stack = [{ node: this, other: otherNode }]; + while (stack.length > 0) { + var pair = stack.pop(); + var node = pair.node; + var other = pair.other; + + if (node.nodeType !== other.nodeType) return false; + + switch (node.nodeType) { + case node.DOCUMENT_TYPE_NODE: + if (node.name !== other.name) return false; + if (node.publicId !== other.publicId) return false; + if (node.systemId !== other.systemId) return false; + break; + case node.ELEMENT_NODE: + if (node.namespaceURI !== other.namespaceURI) return false; + if (node.prefix !== other.prefix) return false; + if (node.localName !== other.localName) return false; + if (node.attributes.length !== other.attributes.length) return false; + for (var i = 0; i < node.attributes.length; i++) { + var attr = node.attributes.item(i); + var otherAttr = other.getAttributeNodeNS(attr.namespaceURI, attr.localName); + if (!otherAttr) return false; + stack.push({ node: attr, other: otherAttr }); } - } - break; - case this.ATTRIBUTE_NODE: - if (this.namespaceURI !== otherNode.namespaceURI) return false; - if (this.localName !== otherNode.localName) return false; - if (this.value !== otherNode.value) return false; - - break; - case this.PROCESSING_INSTRUCTION_NODE: - if (this.target !== otherNode.target || this.data !== otherNode.data) { - return false; - } - break; - case this.TEXT_NODE: - case this.CDATA_SECTION_NODE: - case this.COMMENT_NODE: - if (this.data !== otherNode.data) return false; - break; - } + break; + case node.ATTRIBUTE_NODE: + if (node.namespaceURI !== other.namespaceURI) return false; + if (node.localName !== other.localName) return false; + if (node.value !== other.value) return false; + break; + case node.PROCESSING_INSTRUCTION_NODE: + if (node.target !== other.target || node.data !== other.data) return false; + break; + case node.TEXT_NODE: + case node.CDATA_SECTION_NODE: + case node.COMMENT_NODE: + if (node.data !== other.data) return false; + break; + } - if (this.childNodes.length !== otherNode.childNodes.length) { - return false; - } + if (node.childNodes.length !== other.childNodes.length) return false; - for (var i = 0; i < this.childNodes.length; i++) { - if (!this.childNodes[i].isEqualNode(otherNode.childNodes[i])) { - return false; + // Push children in reverse order so index 0 is processed first (LIFO). + for (var i = node.childNodes.length - 1; i >= 0; i--) { + stack.push({ node: node.childNodes[i], other: other.childNodes[i] }); } }
test/recursion-regression.test.js+5 −0 modified@@ -69,4 +69,9 @@ describe('deep tree stack overflow guard (GHSA-2v35-w6hq-6mfw)', () => { test('normalize', () => { expect(() => deepRoot.normalize()).not.toThrow(); }); + test('isEqualNode', () => { + // cloneNode is already iterative — use it to build the matching second tree + const deepClone = deepRoot.cloneNode(true); + expect(() => deepRoot.isEqualNode(deepClone)).not.toThrow(); + }); });
430357c7b633fix: prevent stack overflow in normalize (GHSA-2v35-w6hq-6mfw)
3 files changed · +26 −12
docs/walk-dom.md+2 −0 modified@@ -104,6 +104,8 @@ supported. Because `lastChild` and `previousSibling` are read after `enter` retu children added or removed there are correctly reflected when the walker schedules the next level of frames. +An example of that is the `Node.prototype.normalize` method. + Mutating anything else — siblings of the current node, ancestors, or unrelated subtrees — produces unpredictable results. Nodes already queued on the stack are visited regardless of subsequent DOM changes; nodes inserted outside the
lib/dom.js+21 −12 modified@@ -1310,7 +1310,7 @@ Node.prototype = { * is `TEXT_NODE`) into a single node with the combined data. It also removes any empty text * nodes. * - * This method operates recursively, so it also normalizes any and all descendent nodes within + * This method iterativly traverses all child nodes to normalize all descendent nodes within * the subtree. * * @throws {DOMException} @@ -1319,19 +1319,28 @@ Node.prototype = { * @since Modified in DOM Level 2 * @see {@link Node.removeChild} * @see {@link CharacterData.appendData} + * @see ../docs/walk-dom.md. */ normalize: function () { - var child = this.firstChild; - while (child) { - var next = child.nextSibling; - if (next && next.nodeType == TEXT_NODE && child.nodeType == TEXT_NODE) { - this.removeChild(next); - child.appendData(next.data); - } else { - child.normalize(); - child = next; - } - } + walkDOM(this, null, { + enter: function (node) { + // Merge adjacent text children of node before walkDOM schedules them. + // walkDOM reads lastChild/previousSibling after enter returns, so the + // surviving post-merge children are what it descends into. + var child = node.firstChild; + while (child) { + var next = child.nextSibling; + if (next !== null && next.nodeType === TEXT_NODE && child.nodeType === TEXT_NODE) { + node.removeChild(next); + child.appendData(next.data); + // Do not advance child: re-check new nextSibling for another text run + } else { + child = next; + } + } + return true; // descend into surviving children + }, + }); }, /** * Checks whether the DOM implementation implements a specific feature and its version.
test/recursion-regression.test.js+3 −0 modified@@ -66,4 +66,7 @@ describe('deep tree stack overflow guard (GHSA-2v35-w6hq-6mfw)', () => { const destDoc = new DOMImplementation().createDocument(null, 'dest'); expect(() => destDoc.importNode(deepRoot, true)).not.toThrow(); }); + test('normalize', () => { + expect(() => deepRoot.normalize()).not.toThrow(); + }); });
e6edcab6bef5fix: make walkDOM iterative to prevent stack overflow (GHSA-2v35-w6hq-6mfw)
6 files changed · +245 −17
docs/walk-dom.md+117 −0 added@@ -0,0 +1,117 @@ +# `walkDOM` — iterative tree traversal + +**Source**: [`lib/dom.js`](../lib/dom.js) + +`walkDOM` visits every node in a DOM subtree in depth-first order, calling +caller-supplied `enter` and `exit` callbacks. It uses an explicit stack instead +of recursion, so it is safe on arbitrarily deep trees. + +## Stack frame + +Each item on the work stack is a frame with three fields: + +| Field | Purpose | +|-----------|------------------------------------------------------| +| `node` | The DOM node to process | +| `context` | Opaque value threaded through the traversal | +| `phase` | `ENTER` — call `enter`, schedule children and exit | +| | `EXIT` — call `exit` for the matching `enter` | + +Each frame starts its life as `ENTER` and, after being processed, causes an +`EXIT` frame for the same node to be pushed. The `EXIT` frame fires only after +all descendants have been fully processed — which is exactly the post-order +guarantee. + +## Control flow + +```mermaid +flowchart TD + start([push ENTER frame for root]) --> loop + + loop{stack empty?} -- no --> pop[pop frame] + loop -- yes --> done([return]) + + pop --> phase{frame.phase} + + phase -- ENTER --> callEnter["childContext = enter(node, context)"] + callEnter --> check{childContext?} + + phase -- EXIT --> callExit["call exit(node, context)\nif provided"] + callExit --> loop + + check -- STOP --> ret([return STOP]) + check -- otherwise --> pushExit["push EXIT frame\n{node, childContext}"] + pushExit --> descend{childContext\nnull or undefined?} + descend -- yes\nskip children --> loop + descend -- no --> pushChildren["push ENTER frames\nfrom lastChild via previousSibling\n(firstChild lands on top)"] + pushChildren --> loop +``` + +`EXIT` is always pushed — even when children are skipped — so `exit` is called +once for every `enter`, symmetric and unconditional. + +## Why pushing EXIT before children guarantees post-order + +The stack is last-in-first-out. When an `ENTER` frame is processed, the following are +pushed in this order: + +1. `EXIT` frame for the current node +2. `ENTER` frames for children, traversed from `lastChild` via `previousSibling` — so `firstChild` lands on top and is processed first + +Because children are on top, they are processed first. The parent's `EXIT` +frame sits below all of them and fires only after the last descendant finishes. + +### Stack trace + +```xml +<root> + <A> + <A1/> + </A> + <B/> +</root> +``` + +| Step | Actions | Stack after <br/>(list order, last item = next to pop) | +|------|-------------------------------------------------------------------------------------------------|-----------------------------------------------------------------| +| init | push `ENTER(root)` | `ENTER(root)` | +| 1 | pop `ENTER(root)`,<br/> `enter(root)`,<br/> push `EXIT(root)`, `ENTER(B)`, `ENTER(A)` | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `ENTER(A)` | +| 2 | pop `ENTER(A)`,<br/> `enter(A)`,<br/> push `EXIT(A)`, `ENTER(A1)` | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `EXIT(A)`,<br/> `ENTER(A1)` | +| 3 | pop `ENTER(A1)`,<br/> `enter(A1)`,<br/> push `EXIT(A1)` | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `EXIT(A)`,<br/> `EXIT(A1)` | +| 4 | pop `EXIT(A1)`,<br/> `exit(A1)` | `EXIT(root)`,<br/> `ENTER(B)`,<br/> `EXIT(A)` | +| 5 | pop `EXIT(A)`,<br/> `exit(A)` | `EXIT(root)`,<br/> `ENTER(B)` | +| 6 | pop `ENTER(B)`,<br/> `enter(B)`,<br/> push `EXIT(B)` | `EXIT(root)`,<br/> `EXIT(B)` | +| 7 | pop `EXIT(B)`,<br/> `exit(B)` | `EXIT(root)` | +| 8 | pop `EXIT(root)`,<br/> `exit(root)` | _(empty)_ | + +Call order: `enter(root)` → `enter(A)` → `enter(A1)` → `exit(A1)` → `exit(A)` → `enter(B)` → `exit(B)` → `exit(root)`. + +## Context isolation + +The value returned by `enter` becomes `childContext`. It is stored in: + +- the `EXIT` frame, so `exit` receives exactly what `enter` returned +- all children's `ENTER` frames, so each child starts with the parent's output + +Siblings share the same `childContext` reference from their common parent. +Callers that need per-element isolation must produce a fresh value inside +`enter` (e.g. `namespaces.slice()` in `serializeToString`). + +## DOM mutation during traversal + +Only mutations to a node's **own children** inside its `enter` callback are +supported. Because `lastChild` and `previousSibling` are read after `enter` returns, any +children added or removed there are correctly reflected when the walker +schedules the next level of frames. + +Mutating anything else — siblings of the current node, ancestors, or unrelated +subtrees — produces unpredictable results. Nodes already queued on the stack +are visited regardless of subsequent DOM changes; nodes inserted outside the +current child list are never queued and therefore never visited. Neither +`enter` nor `exit` is guaranteed to be called for such nodes. + +## `walkDOM.STOP` + +Returning `walkDOM.STOP` from `enter` causes the function to return `STOP` +immediately, discarding the rest of the stack. No further `enter` or `exit` +calls are made — including any pending `EXIT` frames for ancestors.
lib/dom.js+49 −17 modified@@ -1578,40 +1578,58 @@ function _visitNode(node, callback) { * sibling traversal continues normally. * 3. If `enter` returns `walkDOM.STOP`, the entire traversal is aborted immediately — no * further `enter` or `exit` calls are made. - * 4. `firstChild` is read **after** `enter` returns, so `enter` may safely modify the node's - * child list before the walker descends. + * 4. `lastChild` and `previousSibling` are read **after** `enter` returns, so `enter` may + * safely modify the node's own child list before the walker descends. Modifying siblings of + * the current node or any other part of the tree produces unpredictable results: nodes already + * queued on the stack are visited regardless of DOM changes, and newly inserted nodes outside + * the current child list are never visited. * 5. Calls `callbacks.exit(node, context)` (if provided) after all of a node's children have * been visited, passing the same `context` that `enter` * returned for that node. * - * Note: this implementation is recursive. It will be converted to an iterative form in a later - * step to eliminate stack-overflow risk on very deep trees. + * This implementation uses an explicit stack and does not recurse — it is safe on arbitrarily + * deep trees. * * @param {Node} node * Root of the subtree to walk. * @param {*} context * Initial context value passed to the root node's `enter`. * @param {{ enter: function(Node, *): *, exit?: function(Node, *): void }} callbacks * @returns {void | walkDOM.STOP} + * @see ../docs/walk-dom.md. */ function walkDOM(node, context, callbacks) { - var childContext = callbacks.enter(node, context); - if (childContext === walkDOM.STOP) { - return walkDOM.STOP; - } - if (childContext !== null && childContext !== undefined) { - var child = node.firstChild; - while (child) { - var next = child.nextSibling; - if (walkDOM(child, childContext, callbacks) === walkDOM.STOP) { + // Each stack frame is {node, context, phase}: + // walkDOM.ENTER — call enter, then push children + // walkDOM.EXIT — call exit + var stack = [{ node: node, context: context, phase: walkDOM.ENTER }]; + while (stack.length > 0) { + var frame = stack.pop(); + if (frame.phase === walkDOM.ENTER) { + var childContext = callbacks.enter(frame.node, frame.context); + if (childContext === walkDOM.STOP) { return walkDOM.STOP; } - child = next; + // Push exit frame before children so it fires after all children are processed (Last In First Out) + stack.push({ node: frame.node, context: childContext, phase: walkDOM.EXIT }); + if (childContext === null || childContext === undefined) { + continue; // skip children + } + // lastChild is read after enter returns, so enter may modify the child list. + var child = frame.node.lastChild; + // Traverse from lastChild backwards so that pushing onto the stack + // naturally yields firstChild on top (processed first). + while (child) { + stack.push({ node: child, context: childContext, phase: walkDOM.ENTER }); + child = child.previousSibling; + } + } else { + // frame.phase === walkDOM.EXIT + if (callbacks.exit) { + callbacks.exit(frame.node, frame.context); + } } } - if (callbacks.exit) { - callbacks.exit(node, childContext); - } } /** @@ -1621,6 +1639,20 @@ function walkDOM(node, context, callbacks) { * @type {symbol} */ walkDOM.STOP = Symbol('walkDOM.STOP'); +/** + * Phase constant for a stack frame that has not yet been visited. + * The `enter` callback is called and children are scheduled. + * + * @type {number} + */ +walkDOM.ENTER = 0; +/** + * Phase constant for a stack frame whose subtree has been fully visited. + * The `exit` callback is called. + * + * @type {number} + */ +walkDOM.EXIT = 1; /** * @typedef DocumentOptions
test/dom/document.test.js+11 −0 modified@@ -197,6 +197,17 @@ describe('Document.prototype', () => { expect(entries[0].nodeName).toBe('entry'); expect(feed.documentElement.childNodes.item(0).nodeName).toBe('entry'); }); + test('getElementsByTagName on a 10,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => { + const impl = new DOMImplementation(); + const doc = impl.createDocument(null, 'root'); + let current = doc.documentElement; + for (let i = 0; i < 10100; i++) { + const child = doc.createElement('n'); + current.appendChild(child); + current = child; + } + expect(() => doc.documentElement.getElementsByTagName('n')).not.toThrow(); + }); }); test('getElementsByTagNameNS', () => { const doc = new DOMParser().parseFromString(
test/dom/node.test.js+35 −0 modified@@ -362,6 +362,16 @@ describe('Node.prototype', () => { expect(clone.childNodes.length).toBe(2); expect(clone.firstChild.nodeValue).toBe('a'); }); + test('cloneNode(true) on a 10,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => { + const root = doc.createElement('root'); + let current = root; + for (let i = 0; i < 10100; i++) { + const child = doc.createElement('n'); + current.appendChild(child); + current = child; + } + expect(() => root.cloneNode(true)).not.toThrow(); + }); }); describe('isSameNode', () => { const impl = new DOMImplementation(); @@ -444,6 +454,19 @@ describe('Node.prototype', () => { }); }); + describe('on deep tree (stack overflow guard)', () => { + test('textContent on a 10,100-depth element tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => { + const impl2 = new DOMImplementation(); + const doc2 = impl2.createDocument(null, 'root'); + let current = doc2.documentElement; + for (let i = 0; i < 10100; i++) { + const child = doc2.createElement('n'); + current.appendChild(child); + current = child; + } + expect(() => void doc2.documentElement.textContent).not.toThrow(); + }); + }); describe('on other node types (returns nodeValue)', () => { test('Text node returns its data', () => { const node = doc.createTextNode('hello'); @@ -563,5 +586,17 @@ describe('Node.prototype', () => { expect(imported.value).toBe('val'); expect(imported.ownerDocument).toBe(dstDoc); }); + test('importNode(node, true) on a 10,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => { + const impl2 = new DOMImplementation(); + const doc2 = impl2.createDocument(null, 'root'); + let current = doc2.documentElement; + for (let i = 0; i < 10100; i++) { + const child = doc2.createElement('n'); + current.appendChild(child); + current = child; + } + const destDoc = impl2.createDocument(null, 'dest'); + expect(() => destDoc.importNode(doc2.documentElement, true)).not.toThrow(); + }); }); });
test/dom/serializer.test.js+13 −0 modified@@ -514,6 +514,19 @@ describe('XMLSerializer.serializeToString', () => { ); }); }); + + describe('deep tree (stack overflow guard)', () => { + test('serializeToString on a 5,100-depth tree succeeds without throwing RangeError (GHSA-2v35-w6hq-6mfw)', () => { + const deepDoc = new DOMImplementation().createDocument(null, 'root'); + let current = deepDoc.documentElement; + for (let i = 0; i < 5100; i++) { + const child = deepDoc.createElement('n'); + current.appendChild(child); + current = child; + } + expect(() => new XMLSerializer().serializeToString(deepDoc.documentElement)).not.toThrow(); + }); + }); }); describe('Node.toString', () => {
test/dom/walk-dom.test.js+20 −0 modified@@ -192,6 +192,26 @@ describe('walkDOM', () => { }); }); + describe('deep tree (stack overflow guard)', () => { + test('walkDOM on a tree of 10,000 depth succeeds without throwing RangeError', () => { + const impl = new DOMImplementation(); + const doc = impl.createDocument(null, 'root'); + let current = doc.documentElement; + for (let i = 0; i < 10000; i++) { + const child = doc.createElement('n'); + current.appendChild(child); + current = child; + } + expect(() => { + walkDOM(doc.documentElement, null, { + enter() { + return 'ctx'; + }, + }); + }).not.toThrow(); + }); + }); + describe('enter modifies firstChild before descent', () => { test('walker visits the modified child list when enter adds a child before returning', () => { const impl = new DOMImplementation();
17678a2a73ecrefactor: migrate serializeToString to walkDOM
1 file changed · +256 −215
lib/dom.js+256 −215 modified@@ -3000,234 +3000,275 @@ function serializeToString(node, buf, visibleNamespaces, opts) { var doc = node.nodeType === DOCUMENT_NODE ? node : node.ownerDocument; var isHTML = doc.type === 'html'; - if (nodeFilter) { - node = nodeFilter(node); - if (node) { - if (typeof node == 'string') { - buf.push(node); - return; - } - } else { - return; - } - //buf.sort.apply(attrs, attributeSorter); - } - - switch (node.nodeType) { - case ELEMENT_NODE: - var attrs = node.attributes; - var len = attrs.length; - var child = node.firstChild; - var nodeName = node.tagName; - - var prefixedNodeName = nodeName; - if (!isHTML && !node.prefix && node.namespaceURI) { - var defaultNS; - // lookup current default ns from `xmlns` attribute - for (var ai = 0; ai < attrs.length; ai++) { - if (attrs.item(ai).name === 'xmlns') { - defaultNS = attrs.item(ai).value; - break; - } - } - if (!defaultNS) { - // lookup current default ns in visibleNamespaces - for (var nsi = visibleNamespaces.length - 1; nsi >= 0; nsi--) { - var namespace = visibleNamespaces[nsi]; - if (namespace.prefix === '' && namespace.namespace === node.namespaceURI) { - defaultNS = namespace.namespace; - break; + walkDOM( + node, + { ns: visibleNamespaces }, + { + enter: function (n, ctx) { + var namespaces = ctx.ns; + + if (nodeFilter) { + n = nodeFilter(n); + if (n) { + if (typeof n == 'string') { + buf.push(n); + return null; } + } else { + return null; } } - if (defaultNS !== node.namespaceURI) { - for (var nsi = visibleNamespaces.length - 1; nsi >= 0; nsi--) { - var namespace = visibleNamespaces[nsi]; - if (namespace.namespace === node.namespaceURI) { - if (namespace.prefix) { - prefixedNodeName = namespace.prefix + ':' + nodeName; + + switch (n.nodeType) { + case ELEMENT_NODE: + var attrs = n.attributes; + var len = attrs.length; + var nodeName = n.tagName; + + var prefixedNodeName = nodeName; + if (!isHTML && !n.prefix && n.namespaceURI) { + var defaultNS; + // lookup current default ns from `xmlns` attribute + for (var ai = 0; ai < attrs.length; ai++) { + if (attrs.item(ai).name === 'xmlns') { + defaultNS = attrs.item(ai).value; + break; + } + } + if (!defaultNS) { + // lookup current default ns in visibleNamespaces + for (var nsi = namespaces.length - 1; nsi >= 0; nsi--) { + var nsEntry = namespaces[nsi]; + if (nsEntry.prefix === '' && nsEntry.namespace === n.namespaceURI) { + defaultNS = nsEntry.namespace; + break; + } + } + } + if (defaultNS !== n.namespaceURI) { + for (var nsi = namespaces.length - 1; nsi >= 0; nsi--) { + var nsEntry = namespaces[nsi]; + if (nsEntry.namespace === n.namespaceURI) { + if (nsEntry.prefix) { + prefixedNodeName = nsEntry.prefix + ':' + nodeName; + } + break; + } + } } - break; } - } - } - } - buf.push('<', prefixedNodeName); + buf.push('<', prefixedNodeName); + + // Build a fresh namespace snapshot for this element's children. + // The slice prevents sibling elements from inheriting each other's declarations. + var childNamespaces = namespaces.slice(); + + for (var i = 0; i < len; i++) { + // add namespaces for attributes + var attr = attrs.item(i); + if (attr.prefix == 'xmlns') { + childNamespaces.push({ + prefix: attr.localName, + namespace: attr.value, + }); + } else if (attr.nodeName == 'xmlns') { + childNamespaces.push({ prefix: '', namespace: attr.value }); + } + } - for (var i = 0; i < len; i++) { - // add namespaces for attributes - var attr = attrs.item(i); - if (attr.prefix == 'xmlns') { - visibleNamespaces.push({ - prefix: attr.localName, - namespace: attr.value, - }); - } else if (attr.nodeName == 'xmlns') { - visibleNamespaces.push({ prefix: '', namespace: attr.value }); - } - } + for (var i = 0; i < len; i++) { + var attr = attrs.item(i); + if (needNamespaceDefine(attr, isHTML, childNamespaces)) { + var attrPrefix = attr.prefix || ''; + var uri = attr.namespaceURI; + addSerializedAttribute(buf, attrPrefix ? 'xmlns:' + attrPrefix : 'xmlns', uri); + childNamespaces.push({ prefix: attrPrefix, namespace: uri }); + } + // Apply nodeFilter and serialize the attribute. + var filteredAttr = nodeFilter ? nodeFilter(attr) : attr; + if (filteredAttr) { + if (typeof filteredAttr === 'string') { + buf.push(filteredAttr); + } else { + addSerializedAttribute(buf, filteredAttr.name, filteredAttr.value); + } + } + } - for (var i = 0; i < len; i++) { - var attr = attrs.item(i); - if (needNamespaceDefine(attr, isHTML, visibleNamespaces)) { - var prefix = attr.prefix || ''; - var uri = attr.namespaceURI; - addSerializedAttribute(buf, prefix ? 'xmlns:' + prefix : 'xmlns', uri); - visibleNamespaces.push({ prefix: prefix, namespace: uri }); - } - serializeToString(attr, buf, visibleNamespaces, opts); - } + // add namespace for current node + if (nodeName === prefixedNodeName && needNamespaceDefine(n, isHTML, childNamespaces)) { + var nodePrefix = n.prefix || ''; + var uri = n.namespaceURI; + addSerializedAttribute(buf, nodePrefix ? 'xmlns:' + nodePrefix : 'xmlns', uri); + childNamespaces.push({ prefix: nodePrefix, namespace: uri }); + } - // add namespace for current node - if (nodeName === prefixedNodeName && needNamespaceDefine(node, isHTML, visibleNamespaces)) { - var prefix = node.prefix || ''; - var uri = node.namespaceURI; - addSerializedAttribute(buf, prefix ? 'xmlns:' + prefix : 'xmlns', uri); - visibleNamespaces.push({ prefix: prefix, namespace: uri }); - } - // in XML elements can be closed when they have no children - var canCloseTag = !child; - if (canCloseTag && (isHTML || node.namespaceURI === NAMESPACE.HTML)) { - // in HTML (doc or ns) only void elements can be closed right away - canCloseTag = isHTMLVoidElement(nodeName); - } - if (canCloseTag) { - buf.push('/>'); - } else { - buf.push('>'); - //if is cdata child node - if (isHTML && isHTMLRawTextElement(nodeName)) { - while (child) { - if (child.data) { - buf.push(child.data); + // in XML elements can be closed when they have no children + var canCloseTag = !n.firstChild; + if (canCloseTag && (isHTML || n.namespaceURI === NAMESPACE.HTML)) { + // in HTML (doc or ns) only void elements can be closed right away + canCloseTag = isHTMLVoidElement(nodeName); + } + if (canCloseTag) { + buf.push('/>'); + // Self-closing: no children and no closing tag needed from exit. + return null; + } + + buf.push('>'); + + // HTML raw text elements: serialize children as raw data without further descent. + if (isHTML && isHTMLRawTextElement(nodeName)) { + var child = n.firstChild; + while (child) { + if (child.data) { + buf.push(child.data); + } else { + serializeToString(child, buf, childNamespaces.slice(), opts); + } + child = child.nextSibling; + } + buf.push('</', prefixedNodeName, '>'); + // Children handled manually above; prevent walkDOM from also traversing them. + return null; + } + + // Return child context so walkDOM descends; exit will emit the closing tag. + return { ns: childNamespaces, tag: prefixedNodeName }; + case DOCUMENT_NODE: + case DOCUMENT_FRAGMENT_NODE: + if (requireWellFormed && n.nodeType === DOCUMENT_NODE && n.documentElement == null) { + throw new DOMException('The Document has no documentElement', DOMExceptionName.InvalidStateError); + } + // Pass namespaces through; each child element will slice independently. + return { ns: namespaces }; + case ATTRIBUTE_NODE: + addSerializedAttribute(buf, n.name, n.value); + return null; + case TEXT_NODE: + /* + * The ampersand character (&) and the left angle bracket (<) must not appear in their literal form, + * except when used as markup delimiters, or within a comment, a processing instruction, + * or a CDATA section. + * If they are needed elsewhere, they must be escaped using either numeric character + * references or the strings `&` and `<` respectively. + * The right angle bracket (>) may be represented using the string " > ", + * and must, for compatibility, be escaped using either `>`, + * or a character reference when it appears in the string `]]>` in content, + * when that string is not marking the end of a CDATA section. + * + * In the content of elements, character data is any string of characters which does not + * contain the start-delimiter of any markup and does not include the CDATA-section-close + * delimiter, `]]>`. + * + * @see https://www.w3.org/TR/xml/#NT-CharData + * @see https://w3c.github.io/DOM-Parsing/#xml-serializing-a-text-node + */ + if (requireWellFormed && g.InvalidChar.test(n.data)) { + throw new DOMException( + 'The Text node data contains characters outside the XML Char production', + DOMExceptionName.InvalidStateError + ); + } + buf.push(n.data.replace(/[<&>]/g, _xmlEncoder)); + return null; + case CDATA_SECTION_NODE: + if (requireWellFormed && n.data.indexOf(']]>') !== -1) { + throw new DOMException('The CDATASection data contains "]]>"', DOMExceptionName.InvalidStateError); + } + if (splitCDATASections) { + buf.push(g.CDATA_START, n.data.replace(/]]>/g, ']]]]><![CDATA[>'), g.CDATA_END); } else { - serializeToString(child, buf, visibleNamespaces.slice(), opts); + buf.push(g.CDATA_START, n.data, g.CDATA_END); } - child = child.nextSibling; - } - } else { - while (child) { - serializeToString(child, buf, visibleNamespaces.slice(), opts); - child = child.nextSibling; - } - } - buf.push('</', prefixedNodeName, '>'); - } - // remove added visible namespaces - //visibleNamespaces.length = startVisibleNamespaces; - return; - case DOCUMENT_NODE: - case DOCUMENT_FRAGMENT_NODE: - if (requireWellFormed && node.nodeType === DOCUMENT_NODE && node.documentElement == null) { - throw new DOMException('The Document has no documentElement', DOMExceptionName.InvalidStateError); - } - var child = node.firstChild; - while (child) { - serializeToString(child, buf, visibleNamespaces.slice(), opts); - child = child.nextSibling; - } - return; - case ATTRIBUTE_NODE: - return addSerializedAttribute(buf, node.name, node.value); - case TEXT_NODE: - /* - * The ampersand character (&) and the left angle bracket (<) must not appear in their literal form, - * except when used as markup delimiters, or within a comment, a processing instruction, - * or a CDATA section. - * If they are needed elsewhere, they must be escaped using either numeric character - * references or the strings `&` and `<` respectively. - * The right angle bracket (>) may be represented using the string " > ", - * and must, for compatibility, be escaped using either `>`, - * or a character reference when it appears in the string `]]>` in content, - * when that string is not marking the end of a CDATA section. - * - * In the content of elements, character data is any string of characters which does not - * contain the start-delimiter of any markup and does not include the CDATA-section-close - * delimiter, `]]>`. - * - * @see https://www.w3.org/TR/xml/#NT-CharData - * @see https://w3c.github.io/DOM-Parsing/#xml-serializing-a-text-node - */ - if (requireWellFormed && g.InvalidChar.test(node.data)) { - throw new DOMException( - 'The Text node data contains characters outside the XML Char production', - DOMExceptionName.InvalidStateError - ); - } - return buf.push(node.data.replace(/[<&>]/g, _xmlEncoder)); - case CDATA_SECTION_NODE: - if (requireWellFormed && node.data.indexOf(']]>') !== -1) { - throw new DOMException('The CDATASection data contains "]]>"', DOMExceptionName.InvalidStateError); - } - if (splitCDATASections) { - return buf.push(g.CDATA_START, node.data.replace(/]]>/g, ']]]]><![CDATA[>'), g.CDATA_END); - } - return buf.push(g.CDATA_START, node.data, g.CDATA_END); - case COMMENT_NODE: - if (requireWellFormed) { - if (g.InvalidChar.test(node.data)) { - throw new DOMException( - 'The comment node data contains characters outside the XML Char production', - DOMExceptionName.InvalidStateError - ); - } - if (node.data.indexOf('--') !== -1 || node.data[node.data.length - 1] === '-') { - throw new DOMException('The comment node data contains "--" or ends with "-"', DOMExceptionName.InvalidStateError); - } - } - return buf.push(g.COMMENT_START, node.data, g.COMMENT_END); - case DOCUMENT_TYPE_NODE: - var pubid = node.publicId; - var sysid = node.systemId; - if (requireWellFormed) { - if (pubid && !g.PubidLiteral_match.test(pubid)) { - throw new DOMException('The DocumentType publicId is not a valid PubidLiteral', DOMExceptionName.InvalidStateError); - } - if (sysid && sysid !== '.' && !g.SystemLiteral_match.test(sysid)) { - throw new DOMException('The DocumentType systemId is not a valid SystemLiteral', DOMExceptionName.InvalidStateError); - } - if (node.internalSubset && node.internalSubset.indexOf(']>') !== -1) { - throw new DOMException('The DocumentType internalSubset contains "]>"', DOMExceptionName.InvalidStateError); - } - } - buf.push(g.DOCTYPE_DECL_START, ' ', node.name); - if (pubid) { - buf.push(' ', g.PUBLIC, ' ', pubid); - if (sysid && sysid !== '.') { - buf.push(' ', sysid); - } - } else if (sysid && sysid !== '.') { - buf.push(' ', g.SYSTEM, ' ', sysid); - } - if (node.internalSubset) { - buf.push(' [', node.internalSubset, ']'); - } - buf.push('>'); - return; - case PROCESSING_INSTRUCTION_NODE: - if (requireWellFormed) { - if (node.target.indexOf(':') !== -1 || node.target.toLowerCase() === 'xml') { - throw new DOMException('The ProcessingInstruction target is not well-formed', DOMExceptionName.InvalidStateError); - } - if (g.InvalidChar.test(node.data)) { - throw new DOMException( - 'The ProcessingInstruction data contains characters outside the XML Char production', - DOMExceptionName.InvalidStateError - ); + return null; + case COMMENT_NODE: + if (requireWellFormed) { + if (g.InvalidChar.test(n.data)) { + throw new DOMException( + 'The comment node data contains characters outside the XML Char production', + DOMExceptionName.InvalidStateError + ); + } + if (n.data.indexOf('--') !== -1 || n.data[n.data.length - 1] === '-') { + throw new DOMException( + 'The comment node data contains "--" or ends with "-"', + DOMExceptionName.InvalidStateError + ); + } + } + buf.push(g.COMMENT_START, n.data, g.COMMENT_END); + return null; + case DOCUMENT_TYPE_NODE: + var pubid = n.publicId; + var sysid = n.systemId; + if (requireWellFormed) { + if (pubid && !g.PubidLiteral_match.test(pubid)) { + throw new DOMException( + 'The DocumentType publicId is not a valid PubidLiteral', + DOMExceptionName.InvalidStateError + ); + } + if (sysid && sysid !== '.' && !g.SystemLiteral_match.test(sysid)) { + throw new DOMException( + 'The DocumentType systemId is not a valid SystemLiteral', + DOMExceptionName.InvalidStateError + ); + } + if (n.internalSubset && n.internalSubset.indexOf(']>') !== -1) { + throw new DOMException('The DocumentType internalSubset contains "]>"', DOMExceptionName.InvalidStateError); + } + } + buf.push(g.DOCTYPE_DECL_START, ' ', n.name); + if (pubid) { + buf.push(' ', g.PUBLIC, ' ', pubid); + if (sysid && sysid !== '.') { + buf.push(' ', sysid); + } + } else if (sysid && sysid !== '.') { + buf.push(' ', g.SYSTEM, ' ', sysid); + } + if (n.internalSubset) { + buf.push(' [', n.internalSubset, ']'); + } + buf.push('>'); + return null; + case PROCESSING_INSTRUCTION_NODE: + if (requireWellFormed) { + if (n.target.indexOf(':') !== -1 || n.target.toLowerCase() === 'xml') { + throw new DOMException('The ProcessingInstruction target is not well-formed', DOMExceptionName.InvalidStateError); + } + if (g.InvalidChar.test(n.data)) { + throw new DOMException( + 'The ProcessingInstruction data contains characters outside the XML Char production', + DOMExceptionName.InvalidStateError + ); + } + if (n.data.indexOf('?>') !== -1) { + throw new DOMException('The ProcessingInstruction data contains "?>"', DOMExceptionName.InvalidStateError); + } + } + buf.push('<?', n.target, ' ', n.data, '?>'); + return null; + case ENTITY_REFERENCE_NODE: + buf.push('&', n.nodeName, ';'); + return null; + //case ENTITY_NODE: + //case NOTATION_NODE: + default: + buf.push('??', n.nodeName); + return null; } - if (node.data.indexOf('?>') !== -1) { - throw new DOMException('The ProcessingInstruction data contains "?>"', DOMExceptionName.InvalidStateError); + }, + exit: function (n, childCtx) { + // Emit the closing tag for elements that were opened (not self-closed, not raw text). + if (childCtx && childCtx.tag) { + buf.push('</', childCtx.tag, '>'); } - } - return buf.push('<?', node.target, ' ', node.data, '?>'); - case ENTITY_REFERENCE_NODE: - return buf.push('&', node.nodeName, ';'); - //case ENTITY_NODE: - //case NOTATION_NODE: - default: - buf.push('??', node.nodeName); - } + }, + } + ); } /** * Imports a node from a different document into `doc`, creating a new copy.
291257493cb0refactor: migrate importNode to walkDOM
2 files changed · +54 −47
index.d.ts+4 −3 modified@@ -1371,11 +1371,12 @@ declare module '@xmldom/xmldom' { localName: string ): LiveNodeList<Element>; /** - * Returns a copy of node. If deep is true, the copy also includes the node's descendants. - * - * If node is a document or a shadow root, throws a "NotSupportedError" DOMException. + * Imports a node from another document into this document, creating a new copy owned by this + * document. If `deep` is true, the copy also includes the node's descendants. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/importNode) + * + * @see {@link https://dom.spec.whatwg.org/#dom-document-importnode} */ importNode<T extends Node>(node: T, deep?: boolean): T; }
lib/dom.js+50 −44 modified@@ -2208,7 +2208,20 @@ Document.prototype = { this.documentElement = newChild; } }, - // Introduced in DOM Level 2: + /** + * Imports a node from another document into this document, creating a new copy owned by this + * document. The source node and its subtree are not modified. + * + * @param {Node} importedNode + * The node to import. + * @param {boolean} deep + * If true, the contents of the node are recursively imported. + * If false, only the node itself (and its attributes, if it is an element) are imported. + * @returns {Node} + * Returns the newly created import of the node. + * @see {@link importNode} + * @see {@link https://dom.spec.whatwg.org/#dom-document-importnode} + */ importNode: function (importedNode, deep) { return importNode(this, importedNode, deep); }, @@ -3216,50 +3229,43 @@ function serializeToString(node, buf, visibleNamespaces, opts) { buf.push('??', node.nodeName); } } +/** + * Imports a node from a different document into `doc`, creating a new copy. + * Delegates to {@link walkDOM} for traversal. Each node in the subtree is shallow-cloned, + * stamped with `doc` as its `ownerDocument`, and detached (`parentNode` set to `null`). + * Children are imported recursively when `deep` is `true`; for {@link Attr} nodes `deep` is + * always forced to `true` + * because an attribute's value lives in a child text node. + * + * @param {Document} doc + * The document that will own the imported node. + * @param {Node} node + * The node to import. + * @param {boolean} deep + * If `true`, descendants are imported recursively. + * @returns {Node} + * The newly imported node, now owned by `doc`. + */ function importNode(doc, node, deep) { - var node2; - switch (node.nodeType) { - case ELEMENT_NODE: - node2 = node.cloneNode(false); - node2.ownerDocument = doc; - //var attrs = node2.attributes; - //var len = attrs.length; - //for(var i=0;i<len;i++){ - //node2.setAttributeNodeNS(importNode(doc,attrs.item(i),deep)); - //} - case DOCUMENT_FRAGMENT_NODE: - break; - case ATTRIBUTE_NODE: - deep = true; - break; - //case ENTITY_REFERENCE_NODE: - //case PROCESSING_INSTRUCTION_NODE: - ////case TEXT_NODE: - //case CDATA_SECTION_NODE: - //case COMMENT_NODE: - // deep = false; - // break; - //case DOCUMENT_NODE: - //case DOCUMENT_TYPE_NODE: - //cannot be imported. - //case ENTITY_NODE: - //case NOTATION_NODE: - //can not hit in level3 - //default:throw e; - } - if (!node2) { - node2 = node.cloneNode(false); //false - } - node2.ownerDocument = doc; - node2.parentNode = null; - if (deep) { - var child = node.firstChild; - while (child) { - node2.appendChild(importNode(doc, child, deep)); - child = child.nextSibling; - } - } - return node2; + var destRoot; + walkDOM(node, null, { + enter: function (srcNode, destParent) { + // Shallow-clone the node and stamp it into the target document. + var destNode = srcNode.cloneNode(false); + destNode.ownerDocument = doc; + destNode.parentNode = null; + // capture as the root of the imported subtree or attach to parent. + if (destParent === null) { + destRoot = destNode; + } else { + destParent.appendChild(destNode); + } + // ATTRIBUTE_NODE must always be imported deeply: its value lives in a child text node. + var shouldDeep = srcNode.nodeType === ATTRIBUTE_NODE || deep; + return shouldDeep ? destNode : null; + }, + }); + return destRoot; } /**
b0620383abc1refactor: migrate cloneNode to walkDOM
1 file changed · +46 −33
lib/dom.js+46 −33 modified@@ -3279,42 +3279,55 @@ function importNode(doc, node, deep) { * potentially invoked in this function) do not meet their specific constraints. */ function cloneNode(doc, node, deep) { - var node2 = new node.constructor(PDC); - for (var n in node) { - if (hasOwn(node, n)) { - var v = node[n]; - if (typeof v != 'object') { - if (v != node2[n]) { - node2[n] = v; + var destRoot; + walkDOM(node, null, { + enter: function (srcNode, destParent) { + // 1. Create a blank node of the same type and copy all scalar own properties. + var destNode = new srcNode.constructor(PDC); + for (var n in srcNode) { + if (hasOwn(srcNode, n)) { + var v = srcNode[n]; + if (typeof v != 'object') { + if (v != destNode[n]) { + destNode[n] = v; + } + } } } - } - } - if (node.childNodes) { - node2.childNodes = new NodeList(); - } - node2.ownerDocument = doc; - switch (node2.nodeType) { - case ELEMENT_NODE: - var attrs = node.attributes; - var attrs2 = (node2.attributes = new NamedNodeMap()); - var len = attrs.length; - attrs2._ownerElement = node2; - for (var i = 0; i < len; i++) { - node2.setAttributeNode(cloneNode(doc, attrs.item(i), true)); + if (srcNode.childNodes) { + destNode.childNodes = new NodeList(); } - break; - case ATTRIBUTE_NODE: - deep = true; - } - if (deep) { - var child = node.firstChild; - while (child) { - node2.appendChild(cloneNode(doc, child, deep)); - child = child.nextSibling; - } - } - return node2; + destNode.ownerDocument = doc; + // 2. Handle node-type-specific setup. + // Attributes are not DOM children, so they are cloned inline here + // rather than by walkDOM descent. + // ATTRIBUTE_NODE forces deep=true so its own children are walked. + var shouldDeep = deep; + switch (destNode.nodeType) { + case ELEMENT_NODE: + var attrs = srcNode.attributes; + var attrs2 = (destNode.attributes = new NamedNodeMap()); + var len = attrs.length; + attrs2._ownerElement = destNode; + for (var i = 0; i < len; i++) { + destNode.setAttributeNode(cloneNode(doc, attrs.item(i), true)); + } + break; + case ATTRIBUTE_NODE: + shouldDeep = true; + } + // 3. Attach to parent, or capture as the root of the cloned subtree. + if (destParent !== null) { + destParent.appendChild(destNode); + } else { + destRoot = destNode; + } + // 4. Return destNode as the context for children (causes walkDOM to descend), + // or null to skip children (shallow clone). + return shouldDeep ? destNode : null; + }, + }); + return destRoot; } function __set__(object, key, value) {
8834218c85acrefactor: migrate textContent getter to walkDOM
2 files changed · +42 −20
index.d.ts+13 −1 modified@@ -448,7 +448,19 @@ declare module '@xmldom/xmldom' { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/previousSibling) */ readonly previousSibling: Node | null; - /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent) */ + /** + * The text content of this node and its descendants. + * + * For {@link Element} and {@link DocumentFragment} nodes, returns the concatenation of the + * `nodeValue` of every descendant text node, excluding processing instruction and comment + * nodes. For all other node types, returns `nodeValue`. + * + * Setting `textContent` on an element or document fragment replaces all child nodes with a + * single text node; on other nodes it sets `data`, `value`, and `nodeValue` directly. + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent) + * + * @see {@link https://dom.spec.whatwg.org/#dom-node-textcontent} + */ textContent: string | null; /**
lib/dom.js+29 −19 modified@@ -3346,9 +3346,37 @@ try { }, }); + /** + * The text content of this node and its descendants. + * + * For {@link Element} and {@link DocumentFragment} nodes, returns the concatenation of the + * `nodeValue` of every descendant text node, excluding processing instruction and comment + * nodes. For all other node types, returns `nodeValue`. + * + * Setting `textContent` on an element or document fragment replaces all child nodes with a + * single text node; on other nodes it sets `data`, `value`, and `nodeValue` directly. + * + * @type {string | null} + * @see {@link https://dom.spec.whatwg.org/#dom-node-textcontent} + */ Object.defineProperty(Node.prototype, 'textContent', { get: function () { - return getTextContent(this); + if (this.nodeType === ELEMENT_NODE || this.nodeType === DOCUMENT_FRAGMENT_NODE) { + var buf = []; + walkDOM(this, null, { + enter: function (n) { + if (n.nodeType === ELEMENT_NODE || n.nodeType === DOCUMENT_FRAGMENT_NODE) { + return true; // enter children + } + if (n.nodeType === PROCESSING_INSTRUCTION_NODE || n.nodeType === COMMENT_NODE) { + return null; // excluded from text content + } + buf.push(n.nodeValue); + }, + }); + return buf.join(''); + } + return this.nodeValue; }, set: function (data) { @@ -3371,24 +3399,6 @@ try { }, }); - function getTextContent(node) { - switch (node.nodeType) { - case ELEMENT_NODE: - case DOCUMENT_FRAGMENT_NODE: - var buf = []; - node = node.firstChild; - while (node) { - if (node.nodeType !== 7 && node.nodeType !== 8) { - buf.push(getTextContent(node)); - } - node = node.nextSibling; - } - return buf.join(''); - default: - return node.nodeValue; - } - } - Object.defineProperty(Element.prototype, 'children', { get: function () { return new LiveNodeList(this, childrenRefresh);
8b7cfd149131refactor: migrate _visitNode to walkDOM
1 file changed · +14 −14
lib/dom.js+14 −14 modified@@ -1548,22 +1548,22 @@ copy(DocumentPosition, Node); copy(DocumentPosition, Node.prototype); /** - * @param callback - * Return true for continue,false for break. - * @returns - * boolean true: break visit; + * Visits every node in the subtree rooted at `node` in depth-first pre-order. + * + * Delegates to {@link walkDOM} for traversal. The `callback` is called on each node; + * if it returns a truthy value, traversal stops immediately. + * + * @param {Node} node + * Root of the subtree to visit. + * @param {function(Node): *} callback + * Called for each node. A truthy return value stops traversal early. */ function _visitNode(node, callback) { - if (callback(node)) { - return true; - } - if ((node = node.firstChild)) { - do { - if (_visitNode(node, callback)) { - return true; - } - } while ((node = node.nextSibling)); - } + walkDOM(node, null, { + enter: function (n) { + return callback(n) ? walkDOM.STOP : true; + }, + }); } /**
2d6d6916ed8arefactor: introduce recursive walkDOM
2 files changed · +274 −0
lib/dom.js+57 −0 modified@@ -1566,6 +1566,62 @@ function _visitNode(node, callback) { } } +/** + * Depth-first pre/post-order DOM tree walker. + * + * Visits every node in the subtree rooted at `node`. For each node: + * + * 1. Calls `callbacks.enter(node, context)` before descending into the node's children. The + * return value becomes the `context` passed to each child's `enter` call and to the matching + * `exit` call. + * 2. If `enter` returns `null` or `undefined`, the node's children are skipped; + * sibling traversal continues normally. + * 3. If `enter` returns `walkDOM.STOP`, the entire traversal is aborted immediately — no + * further `enter` or `exit` calls are made. + * 4. `firstChild` is read **after** `enter` returns, so `enter` may safely modify the node's + * child list before the walker descends. + * 5. Calls `callbacks.exit(node, context)` (if provided) after all of a node's children have + * been visited, passing the same `context` that `enter` + * returned for that node. + * + * Note: this implementation is recursive. It will be converted to an iterative form in a later + * step to eliminate stack-overflow risk on very deep trees. + * + * @param {Node} node + * Root of the subtree to walk. + * @param {*} context + * Initial context value passed to the root node's `enter`. + * @param {{ enter: function(Node, *): *, exit?: function(Node, *): void }} callbacks + * @returns {void | walkDOM.STOP} + */ +function walkDOM(node, context, callbacks) { + var childContext = callbacks.enter(node, context); + if (childContext === walkDOM.STOP) { + return walkDOM.STOP; + } + if (childContext !== null && childContext !== undefined) { + var child = node.firstChild; + while (child) { + var next = child.nextSibling; + if (walkDOM(child, childContext, callbacks) === walkDOM.STOP) { + return walkDOM.STOP; + } + child = next; + } + } + if (callbacks.exit) { + callbacks.exit(node, childContext); + } +} + +/** + * Sentinel value returned from a `walkDOM` `enter` callback to abort the entire traversal + * immediately. + * + * @type {symbol} + */ +walkDOM.STOP = Symbol('walkDOM.STOP'); + /** * @typedef DocumentOptions * @property {string} [contentType=MIME_TYPE.XML_APPLICATION] @@ -3377,4 +3433,5 @@ exports.NodeList = NodeList; exports.Notation = Notation; exports.Text = Text; exports.ProcessingInstruction = ProcessingInstruction; +exports.walkDOM = walkDOM; exports.XMLSerializer = XMLSerializer;
test/dom/walk-dom.test.js+217 −0 added@@ -0,0 +1,217 @@ +'use strict'; + +const { describe, test, expect, beforeEach } = require('@jest/globals'); +const { DOMImplementation, walkDOM } = require('../../lib/dom'); + +/** + * Build a small DOM tree for testing: + * + * root (Element) + * childA (Element) + * grandchildA1 (Element) + * grandchildA2 (Element) + * childB (Element) + * grandchildB1 (Element) + */ +function buildTree() { + const impl = new DOMImplementation(); + const doc = impl.createDocument(null, 'root'); + const root = doc.documentElement; + + const childA = doc.createElement('childA'); + root.appendChild(childA); + const grandchildA1 = doc.createElement('grandchildA1'); + childA.appendChild(grandchildA1); + const grandchildA2 = doc.createElement('grandchildA2'); + childA.appendChild(grandchildA2); + const childB = doc.createElement('childB'); + root.appendChild(childB); + const grandchildB1 = doc.createElement('grandchildB1'); + childB.appendChild(grandchildB1); + + return { doc, root, childA, grandchildA1, grandchildA2, childB, grandchildB1 }; +} + +describe('walkDOM', () => { + describe('pre-order entry', () => { + test('calls enter on parent before children', () => { + const { root } = buildTree(); + const visited = []; + walkDOM(root, null, { + enter(node) { + visited.push(node.nodeName); + // Descend into root so childA/childB are visited, but skip grandchildren + if (node === root) return 'ctx'; + return null; + }, + }); + // root entered first; childA and childB entered; grandchildren skipped + expect(visited).toEqual(['root', 'childA', 'childB']); + }); + + test('visits all nodes in depth-first pre-order when enter returns a truthy context', () => { + const { root } = buildTree(); + const visited = []; + walkDOM(root, 'ctx', { + enter(node) { + visited.push(node.nodeName); + return 'ctx'; + }, + }); + expect(visited).toEqual(['root', 'childA', 'grandchildA1', 'grandchildA2', 'childB', 'grandchildB1']); + }); + }); + + describe('post-order exit', () => { + test('calls exit on a node after all its children have been exited', () => { + const { root } = buildTree(); + const log = []; + walkDOM(root, 'ctx', { + enter(node) { + log.push('enter:' + node.nodeName); + return 'ctx'; + }, + exit(node) { + log.push('exit:' + node.nodeName); + }, + }); + // grandchildA1 exited before childA, childA exited before root + const grandchildA1Exit = log.indexOf('exit:grandchildA1'); + const childAExit = log.indexOf('exit:childA'); + const rootExit = log.indexOf('exit:root'); + expect(grandchildA1Exit).toBeLessThan(childAExit); + expect(childAExit).toBeLessThan(rootExit); + }); + + test('passes the context returned by enter to exit', () => { + const impl = new DOMImplementation(); + const doc = impl.createDocument(null, 'root'); + const root = doc.documentElement; + root.appendChild(doc.createElement('child')); + + const exitContexts = []; + walkDOM(root, 0, { + enter(node, ctx) { + return ctx + 1; + }, + exit(node, ctx) { + exitContexts.push({ name: node.nodeName, ctx }); + }, + }); + // root enter gets ctx=0, returns 1; child enter gets ctx=1, returns 2 + // child exit gets ctx=2; root exit gets ctx=1 + expect(exitContexts).toEqual([ + { name: 'child', ctx: 2 }, + { name: 'root', ctx: 1 }, + ]); + }); + }); + + describe('context propagation', () => { + test('each child receives the return value of its parent enter', () => { + const { root } = buildTree(); + const receivedCtx = {}; + walkDOM(root, 0, { + enter(node, ctx) { + receivedCtx[node.nodeName] = ctx; + return ctx + 1; + }, + }); + expect(receivedCtx['root']).toBe(0); + expect(receivedCtx['childA']).toBe(1); + expect(receivedCtx['grandchildA1']).toBe(2); + expect(receivedCtx['grandchildA2']).toBe(2); + expect(receivedCtx['childB']).toBe(1); + expect(receivedCtx['grandchildB1']).toBe(2); + }); + }); + + describe('STOP sentinel', () => { + test('returning walkDOM.STOP from enter aborts the entire traversal immediately', () => { + const { root } = buildTree(); + const visited = []; + walkDOM(root, null, { + enter(node) { + visited.push(node.nodeName); + if (node.nodeName === 'childA') { + return walkDOM.STOP; + } + return 'ctx'; + }, + exit(node) { + visited.push('exit:' + node.nodeName); + }, + }); + // root and childA entered; traversal stops at childA — no children of childA, + // no siblings (childB), no exit calls + expect(visited).toEqual(['root', 'childA']); + }); + }); + + describe('skip children', () => { + test('returning null from enter skips children but continues with siblings', () => { + const { root } = buildTree(); + const visited = []; + walkDOM(root, null, { + enter(node) { + visited.push(node.nodeName); + if (node.nodeName === 'childA') { + return null; // skip grandchildA1, grandchildA2 + } + return 'ctx'; + }, + }); + expect(visited).toContain('root'); + expect(visited).toContain('childA'); + expect(visited).not.toContain('grandchildA1'); + expect(visited).not.toContain('grandchildA2'); + // childB and its child are still walked (null only skips childA's children) + expect(visited).toContain('childB'); + expect(visited).toContain('grandchildB1'); + }); + + test('returning undefined from enter skips children but continues with siblings', () => { + const { root } = buildTree(); + const visited = []; + walkDOM(root, null, { + enter(node) { + visited.push(node.nodeName); + if (node.nodeName === 'childA') { + return undefined; // skip grandchildA1, grandchildA2 + } + return 'ctx'; + }, + }); + expect(visited).toContain('root'); + expect(visited).toContain('childA'); + expect(visited).not.toContain('grandchildA1'); + expect(visited).not.toContain('grandchildA2'); + // childB and its child are still walked (undefined only skips childA's children) + expect(visited).toContain('childB'); + expect(visited).toContain('grandchildB1'); + }); + }); + + describe('enter modifies firstChild before descent', () => { + test('walker visits the modified child list when enter adds a child before returning', () => { + const impl = new DOMImplementation(); + const doc = impl.createDocument(null, 'root'); + const root = doc.documentElement; + const original = doc.createElement('original'); + root.appendChild(original); + + const visited = []; + walkDOM(root, null, { + enter(node) { + visited.push(node.nodeName); + if (node.nodeName === 'root') { + // Add a new child before returning — walker should see it + root.appendChild(doc.createElement('added')); + } + return 'ctx'; + }, + }); + expect(visited).toContain('added'); + }); + }); +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
14- github.com/advisories/GHSA-2v35-w6hq-6mfwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41673ghsaADVISORY
- github.com/xmldom/xmldom/commit/17678a2a73ecbd1a2da90f3d47dc23da9cef81aanvdWEB
- github.com/xmldom/xmldom/commit/291257493cb0eb6980eda83b162a9c4e6d7d2597nvdWEB
- github.com/xmldom/xmldom/commit/2d6d6916ed8a4c223db1f6d7560ab4544c465b0fnvdWEB
- github.com/xmldom/xmldom/commit/430357c7b6333108856e917bf2367afe5ceb6f8anvdWEB
- github.com/xmldom/xmldom/commit/4845ef109221df0890825de2822fbe77afba3afenvdWEB
- github.com/xmldom/xmldom/commit/8834218c85ac2a4d757b9587c9028e67c2f7b6c3nvdWEB
- github.com/xmldom/xmldom/commit/8b7cfd1491314abdc347261921d7334ff15f7112nvdWEB
- github.com/xmldom/xmldom/commit/b0620383abc1df067f3ce1014c43ae1bc1161eebnvdWEB
- github.com/xmldom/xmldom/commit/e6edcab6bef5bcdba0b220bb35442aa72f452b84nvdWEB
- github.com/xmldom/xmldom/releases/tag/0.8.13nvdWEB
- github.com/xmldom/xmldom/releases/tag/0.9.10nvdWEB
- github.com/xmldom/xmldom/security/advisories/GHSA-2v35-w6hq-6mfwnvdWEB
News mentions
1- Patch Tuesday - May 2026Rapid7 Blog · May 13, 2026