Repository navigation
v0.18.13 - Empty non-void HTML elements inside <foreignObject> are serialized as XML self-closing (<div/>) #329
Description
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.
Activity
WebReflection commentedon Jul 12, 2026
What’s the use case ?
Nashorn commentedon Jul 13, 2026
Hey Andrea,
Use case: we use headless HTML transform pipeline whose output is re-parsed by a real browser.
We use linkedom server-side (Node/Electron) to post-process saved web pages: parse the page's HTML → run a chain of DOM transforms → serialize back to an
.htmlfile that is then opened in an actual browser.So linkedom is the headless DOM in a
parse → mutate → serializeround-trip, and the serialized output has to be HTML that a browser re-parses the same way linkedom parsed it.Where it breaks: real pages (e.g. google.com's inline SVG icons) contain
<foreignObject><div>…</div></foreignObject>.<foreignObject>is an HTML integration point, so that<div>is in the HTML namespace — and HTML has no self-closing for non-void elements. linkedom emits<div/>; when the browser re-parses the file, the/is ignored, the<div>never closes, and it swallows every following sibling — the whole page renders blank.Minimal testcase showing the broken invariant:
WebReflection commentedon Jul 14, 2026
would this solve? it's tested against your failure ... there's also a question in there I cannot bother answering 'cause maybe some browser does weird things and some other doesn't but if the parsing can be stacked I guess stack it is: #330
PolariTOON commentedon Jul 14, 2026
Related PR comment where i provided a similar testcase with a
divinside aforeignObject(in the case of XHTML documents): #305 (comment)WebReflection commentedon Jul 14, 2026
@PolariTOON this was my reason to close that comment and it remains: #305 (comment)