From d4eb2f81b6ee25eed53cd22ecdb6e56cb3d08486 Mon Sep 17 00:00:00 2001 From: lizarusi Date: Thu, 13 Aug 2026 13:40:20 +0200 Subject: [PATCH 1/3] Add failing tests: outerHTML morph of a text or comment oldNode throws TypeError --- test/core.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/core.js b/test/core.js index 0c3ce41..fdbfe5d 100644 --- a/test/core.js +++ b/test/core.js @@ -121,6 +121,28 @@ describe("Core morphing tests", function () { ); }); + it("morphs outerHTML properly when oldNode is a text node", function () { + let parent = make("
Foo
"); + let initial = parent.firstChild; + Idiomorph.morph(initial, "", { + morphStyle: "outerHTML", + }); + parent.innerHTML.should.equal(""); + }); + + it("morphs outerHTML properly when oldNode is a comment node", function () { + let parent = make( + "

Before

After

", + ); + let initial = parent.childNodes[1]; + Idiomorph.morph(initial, "", { + morphStyle: "outerHTML", + }); + parent.innerHTML.should.equal( + "

Before

After

", + ); + }); + it("morphs innerHTML as content properly when argument is null", function () { let initial = make("
Foo
"); Idiomorph.morph(initial, null, { morphStyle: "innerHTML" }); From 91e0f0e84eb987b595e2b9c84170dbdac0a8f3c4 Mon Sep 17 00:00:00 2001 From: lizarusi Date: Thu, 13 Aug 2026 13:40:52 +0200 Subject: [PATCH 2/3] Guard findIdElements against roots without querySelectorAll Morphing a text or comment node with morphStyle: "outerHTML" throws "TypeError: root.querySelectorAll is not a function", because morph() passes the raw oldNode into createIdMaps -> findIdElements. Guarding with optional chaining (same pattern as the getAttribute guard below) lets the morph proceed; the rest of the algorithm already handles non-element nodes correctly. --- src/idiomorph.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index 76ac984..fae66d5 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -1104,7 +1104,8 @@ var Idiomorph = (function () { function findIdElements(root) { /** @type {IdElement[]} */ let elements = []; - for (const elt of root.querySelectorAll("[id]")) { + // root could be a text or comment node which doesn't have `querySelectorAll` + for (const elt of root.querySelectorAll?.("[id]") ?? []) { // elt.id is unsafe because of form input shadowing, and `id=""` is not persistable const id = elt.getAttribute("id"); if (id) elements.push({ elt, id }); From 5e85a2c7310073471af3a2a103bb6a0653dd7ee5 Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Sun, 16 Aug 2026 18:31:13 +0200 Subject: [PATCH 3/3] widen morph()'s oldNode to Node, and reject innerHTML on nodes that cannot have children --- src/idiomorph.js | 41 ++++++++++++++++++++++++----------------- test/core.js | 8 ++++++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index fae66d5..bd3a69f 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -87,7 +87,7 @@ /** * @callback Morph * - * @param {Element | Document} oldNode + * @param {Node} oldNode * @param {Node | HTMLCollection | Node[] | string | null} newContent * @param {Config} [config] * @returns {Promise | Node[]} @@ -152,27 +152,27 @@ var Idiomorph = (function () { /** * Core idiomorph function for morphing one DOM tree to another * - * @param {Element | Document} oldNode + * @param {Node} oldNode * @param {Node | HTMLCollection | Node[] | string | null} newContent * @param {Config} [config] * @returns {Promise | Node[]} */ function morph(oldNode, newContent, config = {}) { - oldNode = normalizeElement(oldNode); + const oldElt = normalizeElement(oldNode); const newNode = normalizeParent(newContent); - const ctx = createMorphContext(oldNode, newNode, config); + const ctx = createMorphContext(oldElt, newNode, config); return withHeadBlocking( ctx, - oldNode, + oldElt, newNode, /** @param {MorphContext} ctx */ (ctx) => { const morphedNodes = saveAndRestoreFocus(ctx, () => { if (ctx.morphStyle === "innerHTML") { - morphChildren(ctx, oldNode, newNode); - return Array.from(oldNode.childNodes); + morphChildren(ctx, oldElt, newNode); + return Array.from(oldElt.childNodes); } else { - return morphOuterHTML(ctx, oldNode, newNode); + return morphOuterHTML(ctx, oldElt, newNode); } }); ctx.pantry.remove(); @@ -1018,6 +1018,10 @@ var Idiomorph = (function () { if (!["innerHTML", "outerHTML"].includes(morphStyle)) { throw `Do not understand how to morph style ${morphStyle}`; } + // Text and Comment have no ParentNode methods, so they cannot take innerHTML + if (morphStyle === "innerHTML" && !oldNode.append) { + throw `Cannot morph the innerHTML of a ${oldNode.nodeName} node, as it cannot have children`; + } const headStyle = mergedConfig.head.style || "merge"; if (!["merge", "append", "morph", "none"].includes(headStyle)) { @@ -1095,24 +1099,26 @@ var Idiomorph = (function () { } /** - * Returns all elements with a non-empty ID contained within the root element and its + * Returns all elements with a non-empty ID contained within the root node and its * descendants, each paired with its id so that it only has to be read once. * - * @param {Element} root + * @param {Node} root * @returns {IdElement[]} */ function findIdElements(root) { /** @type {IdElement[]} */ let elements = []; - // root could be a text or comment node which doesn't have `querySelectorAll` - for (const elt of root.querySelectorAll?.("[id]") ?? []) { + // root could be a text or comment node which has no `querySelectorAll`, + // or a document fragment which has no `getAttribute` + const rootElt = /** @type {Partial} */ (root); + for (const elt of rootElt.querySelectorAll?.("[id]") ?? []) { // elt.id is unsafe because of form input shadowing, and `id=""` is not persistable const id = elt.getAttribute("id"); if (id) elements.push({ elt, id }); } - // root could be a document fragment which doesn't have `getAttribute` - const rootId = root.getAttribute?.("id"); - if (rootId) elements.push({ elt: root, id: rootId }); + const rootId = rootElt.getAttribute?.("id"); + if (rootId) + elements.push({ elt: /** @type {Element} */ (root), id: rootId }); return elements; } @@ -1231,14 +1237,15 @@ var Idiomorph = (function () { /** * - * @param {Element | Document} content + * @param {Node} content * @returns {Element} */ function normalizeElement(content) { if (content instanceof Document) { return content.documentElement; } else { - return content; + // a Text or Comment node is not an Element, but morphOuterHTML only ever reads Node members off it + return /** @type {Element} */ (content); } } diff --git a/test/core.js b/test/core.js index fdbfe5d..83f49e1 100644 --- a/test/core.js +++ b/test/core.js @@ -200,6 +200,14 @@ describe("Core morphing tests", function () { }).should.throw("Do not understand how to morph style magic"); }); + it("errors on innerHTML of a node that cannot have children", function () { + (() => { + Idiomorph.morph(make("
Foo
").firstChild, [], { + morphStyle: "innerHTML", + }); + }).should.throw("Cannot morph the innerHTML of a #text node"); + }); + it("errors on bad head style", function () { (() => { Idiomorph.morph(make("

"), [], { head: { style: "magic" } });