An unclosed tag leaves the parser's internal parent back-reference attached to the returned object, so the result is circular and xml2json throws:
const convert = require('xml-js');
convert.xml2json('<a>');
TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'elements' -> object with constructor 'Array'
| index 0 -> object with constructor 'Object'
--- property 'parent' closes the circle
at JSON.stringify (<anonymous>)
at module.exports [as xml2json] (…/xml-js/lib/xml2json.js:19:17)
xml2js doesn't throw, but returns a circular object:
const js = convert.xml2js('<a>');
js.elements[0].parent === js; // true
That also contradicts the documented default — the README says parent appears only "by setting {addParent: true}", and it defaults to false.
Reproduced on xml-js@1.6.11 and present at master (f0376f2).
Cause
lib/xml2js.js:250 attaches the back-reference with the comment "will be deleted in onEndElement() if !options.addParent", and onEndElement (line 283) is the only place that removes it. For an unclosed tag onEndElement never runs, so the reference survives into the returned object.
The error that should have surfaced can't: sax raises "Unclosed root tag" from inside end(), after write()'s entry-time error check, and onError (line 312) is error.note = error, which discards it.
This produces a telling asymmetry:
| input |
result |
<a></b> (mismatched) |
clean Error: Unmatched closing tag: b — raised during write() |
<a> (unclosed) |
circular object / TypeError — raised during end(), unreachable |
Why it matters
Truncated XML is ordinary production input — a cut-off feed, a partial upload, a socket closed mid-response. The failure surfaces as a JSON.stringify error far from the parse, or as an object that silently breaks deep-equality, structured cloning and res.json().
Suggested fix
Unwind anything left open after parsing, mirroring the cleanup already in onEndElement. Deliberately conservative — it preserves the library's lenient "don't throw on unclosed tags" behaviour rather than changing onError, so it isn't a breaking change.
while (currentElement && currentElement !== result) {
var unclosedParent = currentElement[options.parentKey];
if (!options.addParent) {
delete currentElement[options.parentKey];
}
currentElement = unclosedParent;
}
Verified: existing suite 2363 specs / 0 failures unchanged, plus 3 regression specs that fail without the change. addParent: true still behaves as documented.
I've opened a PR with this.
Environment: xml-js@1.6.11, Node 24.16.0, macOS.
An unclosed tag leaves the parser's internal
parentback-reference attached to the returned object, so the result is circular andxml2jsonthrows:xml2jsdoesn't throw, but returns a circular object:That also contradicts the documented default — the README says
parentappears only "by setting{addParent: true}", and it defaults tofalse.Reproduced on
xml-js@1.6.11and present atmaster(f0376f2).Cause
lib/xml2js.js:250attaches the back-reference with the comment "will be deleted in onEndElement() if !options.addParent", andonEndElement(line 283) is the only place that removes it. For an unclosed tagonEndElementnever runs, so the reference survives into the returned object.The error that should have surfaced can't: sax raises "Unclosed root tag" from inside
end(), afterwrite()'s entry-time error check, andonError(line 312) iserror.note = error, which discards it.This produces a telling asymmetry:
<a></b>(mismatched)Error: Unmatched closing tag: b— raised duringwrite()<a>(unclosed)TypeError— raised duringend(), unreachableWhy it matters
Truncated XML is ordinary production input — a cut-off feed, a partial upload, a socket closed mid-response. The failure surfaces as a
JSON.stringifyerror far from the parse, or as an object that silently breaks deep-equality, structured cloning andres.json().Suggested fix
Unwind anything left open after parsing, mirroring the cleanup already in
onEndElement. Deliberately conservative — it preserves the library's lenient "don't throw on unclosed tags" behaviour rather than changingonError, so it isn't a breaking change.Verified: existing suite 2363 specs / 0 failures unchanged, plus 3 regression specs that fail without the change.
addParent: truestill behaves as documented.I've opened a PR with this.
Environment:
xml-js@1.6.11, Node 24.16.0, macOS.