Empty non-void HTML elements inside <foreignObject> are serialized as XML self-closing (<div/>), producing invalid HTML that swallows following content
Summary
When an empty HTML element (e.g. <div>, <p>, <span>) appears inside an SVG <foreignObject>, parseHTML(...).document.toString() / .innerHTML serializes it using XML self-closing syntax (<div/>) instead of an explicit start/end tag pair (<div></div>).
Per the HTML standard, the self-closing slash is ignored on non-void HTML elements, so <div/> is parsed as an open <div>. Because <foreignObject> is an HTML integration point (its subtree is parsed as HTML, not SVG), this output is invalid: when a spec-compliant HTML parser (i.e. any browser) re-parses it, the unclosed <div> swallows every following sibling — the rest of the <foreignObject>, the rest of the <svg>, and all content after it.
The result is that a serialized document round-tripped through linkedom renders blank / severely mis-nested in a browser.
Environment
- linkedom: 0.18.13
- Node: v22.16.0
Minimal reproduction
const { parseHTML } = require('linkedom');
const input = '<svg><foreignObject><div></div></foreignObject></svg>';
const { document } = parseHTML('<!DOCTYPE html><html><body>' + input + '</body></html>');
console.log(document.body.innerHTML);
Actual output
<svg><foreignobject><div /></foreignobject></svg>
Expected output
<svg><foreignObject><div></div></foreignObject></svg>
<div> is a non-void HTML element and must be serialized as <div></div>. (foreignObject should also retain its camelCase — see note below.)
Scope / characterization
Only empty non-void elements in an SVG/foreignObject context are affected:
| Input |
Output |
Correct? |
<svg><foreignObject><div></div></foreignObject></svg> |
<div /> |
❌ self-closed |
<svg><foreignObject><p></p></foreignObject></svg> |
<p /> |
❌ self-closed |
<svg><foreignObject><div>hi</div></foreignObject></svg> |
<div>hi</div> |
✅ (non-empty) |
<div></div> (plain HTML) |
<div></div> |
✅ (outside SVG) |
So the trigger is: an empty element serialized while it is (incorrectly) treated as being in the XML/SVG namespace, even though <foreignObject> switches its subtree back to the HTML namespace.
Why it matters
<div/> is not equivalent to <div></div> in HTML. Given sibling content:
parseHTML('<!DOCTYPE html><body><svg><foreignObject><div></div><span>X</span></foreignObject></svg>')
.document.body.innerHTML
// => <svg><foreignobject><div /><span>X</span></foreignobject></svg>
linkedom keeps <span> as a sibling internally, but the emitted string is not round-trippable: a browser parsing <div /><span>X</span> inside a foreignObject nests <span> (and everything after) inside the <div>. Real-world impact: serializing a captured page that contains an inline SVG icon built with <foreignObject><div .../></foreignObject> (common on e.g. google.com) makes the whole page render blank.
Suggested fix
Elements inside a <foreignObject> are in the HTML namespace and should follow HTML serialization rules: only the HTML void elements (area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr) may be self-closed; all other elements must be serialized with an explicit end tag.
Empty non-void HTML elements inside
<foreignObject>are serialized as XML self-closing (<div/>), producing invalid HTML that swallows following contentSummary
When an empty HTML element (e.g.
<div>,<p>,<span>) appears inside an SVG<foreignObject>,parseHTML(...).document.toString()/.innerHTMLserializes it using XML self-closing syntax (<div/>) instead of an explicit start/end tag pair (<div></div>).Per the HTML standard, the self-closing slash is ignored on non-void HTML elements, so
<div/>is parsed as an open<div>. Because<foreignObject>is an HTML integration point (its subtree is parsed as HTML, not SVG), this output is invalid: when a spec-compliant HTML parser (i.e. any browser) re-parses it, the unclosed<div>swallows every following sibling — the rest of the<foreignObject>, the rest of the<svg>, and all content after it.The result is that a serialized document round-tripped through linkedom renders blank / severely mis-nested in a browser.
Environment
Minimal reproduction
Actual output
Expected output
<div>is a non-void HTML element and must be serialized as<div></div>. (foreignObjectshould also retain its camelCase — see note below.)Scope / characterization
Only empty non-void elements in an SVG/foreignObject context are affected:
<svg><foreignObject><div></div></foreignObject></svg><div /><svg><foreignObject><p></p></foreignObject></svg><p /><svg><foreignObject><div>hi</div></foreignObject></svg><div>hi</div><div></div>(plain HTML)<div></div>So the trigger is: an empty element serialized while it is (incorrectly) treated as being in the XML/SVG namespace, even though
<foreignObject>switches its subtree back to the HTML namespace.Why it matters
<div/>is not equivalent to<div></div>in HTML. Given sibling content:linkedom keeps
<span>as a sibling internally, but the emitted string is not round-trippable: a browser parsing<div /><span>X</span>inside a foreignObject nests<span>(and everything after) inside the<div>. Real-world impact: serializing a captured page that contains an inline SVG icon built with<foreignObject><div .../></foreignObject>(common on e.g. google.com) makes the whole page render blank.Suggested fix
Elements inside a
<foreignObject>are in the HTML namespace and should follow HTML serialization rules: only the HTML void elements (area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr) may be self-closed; all other elements must be serialized with an explicit end tag.