From 1d1e24d3f5baff987a5e33d75155e53ca06325cd Mon Sep 17 00:00:00 2001 From: Guo Liu Date: Sat, 4 Jul 2026 23:12:34 +0200 Subject: [PATCH] fix(createNode): recreate id-bearing nodes with createElementNS so inline SVG survives morphs createNode has two insertion paths. The stateless branch clones with document.importNode, which preserves the source node's namespace. The id-preserving branch, however, rebuilt its dummy with document.createElement(newChild.tagName), which ALWAYS creates an HTML-namespaced element. A rebuilt inline is therefore not an SVGElement, so morphAttributes' plain setAttribute folds its case-sensitive names (viewBox -> viewbox, clipPathUnits, ...) and the graphic renders blank even though it still shows in the DOM inspector. This only bites id-bearing SVG subtrees, which is a sharp trap: the library's own guidance (#57) is to ADD an id to preserve state across a morph, precisely what can route a node into this branch. Fix: recreate the node in its OWN namespace with document.createElementNS(newChild.namespaceURI, newChild.localName). - namespaceURI: SVG/MathML get their real namespace, so setAttribute no longer folds case-sensitive names. - localName, not tagName: createElementNS does NOT case-normalize its qualified-name argument, and tagName is UPPERCASE for HTML ("NAV"). localName is correct in every namespace, and createElementNS(htmlNS, "nav") yields an ordinary HTMLElement identical to createElement("nav"), so HTML behavior is unchanged. morphdom fixed the same class of bug with namespace-aware node creation (morphdom #34 / PR #61). dist/ is intentionally left untouched: this repo regenerates dist/ only at release time (see RELEASE.md), so contributor changes touch src/ only. --- src/idiomorph.js | 10 ++++++++-- test/core.js | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index 257c040..5a619c8 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -368,8 +368,14 @@ var Idiomorph = (function () { if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; if (ctx.idMap.has(newChild)) { // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm - const newEmptyChild = document.createElement( - /** @type {Element} */ (newChild).tagName, + // Recreate in the node's OWN namespace (keyed on localName, not the + // uppercase-for-HTML tagName — createElementNS does NOT case-normalize). + // Plain createElement always makes an HTML-namespaced element, so a rebuilt + // inline loses the SVG namespace and setAttribute folds case-sensitive + // names (viewBox -> viewbox), rendering the graphic blank. + const newEmptyChild = document.createElementNS( + /** @type {Element} */ (newChild).namespaceURI, + /** @type {Element} */ (newChild).localName, ); oldParent.insertBefore(newEmptyChild, insertionPoint); morphNode(newEmptyChild, newChild, ctx); diff --git a/test/core.js b/test/core.js index 5e10210..a6a04ad 100644 --- a/test/core.js +++ b/test/core.js @@ -607,3 +607,44 @@ describe("Core morphing tests", function () { initial.outerHTML.should.equal("Bar"); }); }); + +describe("Foreign (SVG) namespace", function () { + setup(); + + // This is a real-browser test (Mocha `test/index.html` / web-test-runner). It + // asserts the intended invariant: an id-bearing inline keeps its SVG + // namespace and case-sensitive `viewBox` across a morph. + // + // Honesty note: on its own this test does NOT discriminate the fix in the + // headless suites. Verified empirically in web-test-runner (headless Chrome) + // and Playwright WebKit: it passes with AND without the fix, because in those + // engines the id-bearing is id-matched and *moved* (or cloned via + // importNode) rather than rebuilt through createNode's dummy -- only the HTML + // wrapper takes the dummy path, which is harmlessly HTML-namespaced anyway. + // The createElement -> HTML-namespace corruption this guards against was + // observed in a production macOS WKWebView app. Keep it in the browser suite + // so any engine that DOES route foreign content through the dummy is covered. + it("recreates an id-bearing inline SVG in the SVG namespace (createNode)", function () { + // A subtree carrying a persistent id takes createNode's id-preserving branch, + // which used document.createElement(tagName) -> HTML namespace. A rebuilt + // then loses its namespace and setAttribute folds viewBox -> viewbox. + let initial = make( + '
' + + '
', + ); + // Wrap the id-bearing in a fresh
so its ancestor must be + // created, which re-anchors the itself to be rebuilt via createNode. + Idiomorph.morph( + initial, + '
' + + '
', + { morphStyle: "outerHTML" }, + ); + + let svg = initial.querySelector("svg"); + svg.namespaceURI.should.equal("http://www.w3.org/2000/svg"); + svg.getAttribute("viewBox").should.equal("0 0 32 32"); + // And HTML wrappers keep a lowercase localName (localName, not tagName). + initial.querySelector("section").localName.should.equal("section"); + }); +});