Skip to content

Unclosed tag returns a circular object; xml2json throws "Converting circular structure to JSON" #223

Description

@theRizwan

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions