Skip to content

fix(createNode): recreate id-bearing nodes with createElementNS so inline SVG survives morphs#154

Open
guoliu wants to merge 1 commit into
bigskysoftware:mainfrom
guoliu:fix/createnode-namespaced-svg
Open

fix(createNode): recreate id-bearing nodes with createElementNS so inline SVG survives morphs#154
guoliu wants to merge 1 commit into
bigskysoftware:mainfrom
guoliu:fix/createnode-namespaced-svg

Conversation

@guoliu

@guoliu guoliu commented Jul 4, 2026

Copy link
Copy Markdown

Problem

Inline SVG (and any foreign/MathML content) can silently lose its namespace when it is rebuilt through createNode's id-preserving branch. The <svg> becomes an HTML-namespaced element (an HTMLUnknownElement), so morphAttributes' plain setAttribute folds its case-sensitive attribute names (viewBoxviewbox, clipPathUnits, preserveAspectRatio, …) and the graphic renders blank even though it still appears in the DOM inspector.

It 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.

Root cause

createNode has two insertion paths:

if (ctx.idMap.has(newChild)) {
  // node has children with ids with possible state -> dummy elt + full morph
  const newEmptyChild = document.createElement(
    /** @type {Element} */ (newChild).tagName,
  );
  ...
} else {
  // no id state -> clone, which preserves namespace + attribute casing
  const newClonedChild = document.importNode(newChild, true);
  ...
}

The stateless branch uses importNode, which preserves the source node's namespace — so most inline SVG survives. The id branch rebuilt the dummy with document.createElement(tagName), which always creates an HTML-namespaced element. A rebuilt <svg> is therefore not an SVGElement, and setAttribute on an HTML-namespaced element folds camelCase names.

Fix

Recreate the node in its own namespace:

const newEmptyChild = document.createElementNS(
  /** @type {Element} */ (newChild).namespaceURI,
  /** @type {Element} */ (newChild).localName,
);

Two details are load-bearing:

  1. namespaceURI — read off the already-parsed source node; SVG/MathML get their real namespace, so the element is a proper SVGElement/MathMLElement and setAttribute no longer folds case-sensitive names.
  2. localName, not tagNamecreateElementNS does not case-normalize its qualified-name argument (unlike createElement). tagName is UPPERCASE for HTML elements ("NAV"), so keying on it would create uppercase-localName HTML nodes (<NAV>) that miss lowercase type-selector CSS. localName is correct in every namespace: "nav" for HTML, "clipPath" for SVG. createElementNS(htmlNS, "nav") yields an ordinary HTMLElement identical to createElement("nav"), so HTML behavior is unchanged.

This mirrors how morphdom handles the same class of bug (namespace-aware node creation; morphdom #34 / PR #61).

Verification — read this carefully

I want to be precise about where this reproduces, because it matters for how to treat the added test.

  • Confirmed in production WebKit (macOS WKWebView). In a real app, a header theme-toggle <svg> containing a <clipPath id="…"> went invisible after the first morph and stayed gone until a full reload; with this patch its viewBox survives repeated morphs and the icon renders. This is the concrete failure that motivated the fix.
  • I could NOT reproduce the corruption in any headless engine available to me. I ran the suite in this repo's own runners — web-test-runner (headless Chrome) and Playwright WebKit — and instrumented the id branch. In both engines an id-bearing inline <svg> is id-matched and moved (or cloned via importNode) rather than rebuilt through the createElement dummy; only the surrounding HTML wrapper (<section>) actually takes the dummy path, and that is harmlessly HTML-namespaced (http://www.w3.org/1999/xhtml). So the createElement → HTML-namespace fold is real but latent in those engines with the fixtures I could construct — it surfaced in the WKWebView app.
  • Consistent with this: in jsdom the corruption also does not reproduce (foreign content is moved via importNode and jsdom doesn't fold viewBox). A jsdom/headless regression test would pass with or without the fix and would be misleading, so I did not add one.

Tests

  • npm run typecheck — passes.
  • npx prettier --check on the changed files — passes.
  • web-test-runner (headless Chrome) and web-test-runner --playwright --browsers webkit — the added test passes; the rest of the suite is green except 4 pre-existing failures in "Option to forcibly restore focus after morph" that also fail on unmodified main in this environment (headless-Chrome selection-state behavior), i.e. unrelated to this change.
  • I added one browser test in test/core.js (describe("Foreign (SVG) namespace")) asserting an id-bearing inline <svg> keeps namespaceURI === "http://www.w3.org/2000/svg" and its viewBox across a morph. Its comment states plainly that it does not discriminate the fix in the headless suites (it passes with and without the patch there) and documents why — it stands as an executable statement of the intended invariant and a guard for any engine that does route foreign content through the dummy. Happy to drop or relocate it if you'd prefer.

dist/

Left untouched on purpose. dist/ in this repo is regenerated only at release time (RELEASE.md step 3, npm run dist); recent bugfix commits (e.g. 8c80098, afd144f) touch src/ only, and the committed dist/ is already a few src/ commits behind main. Rebuilding it here would pull unrelated changes into this PR, so I've kept this to src/ + the test to match the project's convention. Glad to regenerate dist/ if you'd rather it ship in the PR.

DOM references

  • Document.createElement always produces an element in the HTML namespace and lowercases its argument; Document.createElementNS(ns, qualifiedName) uses the given namespace and does not case-normalize (MDN Document.createElementNS).
  • setAttribute lowercases the qualified name only when the element is in the HTML namespace in an HTML document — which is exactly why an HTML-namespaced <svg> folds viewBox.

Related

…line 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 <svg> 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 (bigskysoftware#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 bigskysoftware#34 / PR bigskysoftware#61).

dist/ is intentionally left untouched: this repo regenerates dist/ only
at release time (see RELEASE.md), so contributor changes touch src/ only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant