From 77ba8e38663d636d516d3e575127b3e7c76edf80 Mon Sep 17 00:00:00 2001 From: lizarusi Date: Thu, 13 Aug 2026 15:10:38 +0200 Subject: [PATCH 1/5] Add failing tests: cross-realm nodes crash morph() and break morphing behaviors One test per realm-sensitive check: entry normalization (crash), id-based element preservation (detached and SlicedParentNode paths), template content morphing, input/option/textarea value syncing, head handling, and Document normalization. --- test/core.js | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/test/core.js b/test/core.js index 83f49e1..c14714d 100644 --- a/test/core.js +++ b/test/core.js @@ -1,5 +1,12 @@ describe("Core morphing tests", function () { setup(); + function makeForeign(htmlStr) { + let iframe = document.createElement("iframe"); + getWorkArea().append(iframe); + let container = iframe.contentDocument.createElement("div"); + container.innerHTML = htmlStr; + return container.firstElementChild; + } it("morphs outerHTML by default", function () { let initial = make(""); @@ -60,6 +67,106 @@ describe("Core morphing tests", function () { initial.outerHTML.should.equal(""); }); + it("morphs outerHTML as content properly when argument is a node from another document", function () { + let initial = make(""); + let iframe = document.createElement("iframe"); + getWorkArea().append(iframe); + let final = iframe.contentDocument.createElement("button"); + final.textContent = "Bar"; + Idiomorph.morph(initial, final, { morphStyle: "outerHTML" }); + initial.outerHTML.should.equal(""); + }); + + it("syncs input value when newContent is a node from another document", function () { + let initial = make(''); + initial.value = "Foo"; + let iframe = document.createElement("iframe"); + getWorkArea().append(iframe); + let final = iframe.contentDocument.createElement("input"); + final.setAttribute("type", "text"); + final.setAttribute("value", "Bar"); + Idiomorph.morph(initial, final, { morphStyle: "outerHTML" }); + initial.value.should.equal("Bar"); + }); + + it("preserves id'd element when detached newContent is from another document", function () { + let initial = make(`
Foo
`); + let span = initial.querySelector("span"); + let final = makeForeign(`
Foo
`); + final.remove(); + Idiomorph.morph(initial, final); + initial.outerHTML.should.equal( + `
Foo
`, + ); + initial.querySelector("span").should.equal(span); + }); + + it("preserves id'd element when attached newContent is from another document", function () { + let initial = make(`
Foo
`); + let span = initial.querySelector("span"); + let final = makeForeign(`
Foo
`); + + Idiomorph.morph(initial, final); + initial.outerHTML.should.equal( + `
Foo
`, + ); + initial.querySelector("span").should.equal(span); + }); + + it("morphs template tag contents when newContent is from another document", function () { + let initial = make(""); + let final = makeForeign(""); + Idiomorph.morph(initial, final); + initial.outerHTML.should.equal(''); + }); + + it("syncs option selectedness when newContent is from another document", function () { + let parent = make( + `
`, + ); + getWorkArea().append(parent); + let select = parent.querySelector("select"); + let final = makeForeign( + ``, + ); + final.children[1].selected = true; + Idiomorph.morph(select, final); + select.selectedIndex.should.equal(1); + }); + + it("syncs textarea value when newContent is from another document", function () { + let initial = make(""); + initial.value = "dirty"; + let final = makeForeign(""); + Idiomorph.morph(initial, final); + initial.value.should.equal("Bar"); + }); + + it("ignores head element from another document when head.style is none", function () { + let iframe = document.createElement("iframe"); + getWorkArea().append(iframe); + let foreignDocument = iframe.contentDocument; + foreignDocument.title = "Old"; + let newHead = foreignDocument.createElement("head"); + newHead.innerHTML = "New"; + Idiomorph.morph(foreignDocument.head, newHead, { + head: { style: "none" }, + }); + foreignDocument.title.should.equal("Old"); + }); + + it("morphs a document from another realm properly", function () { + let iframe = document.createElement("iframe"); + getWorkArea().append(iframe); + let foreignDocument = iframe.contentDocument; + foreignDocument.body.innerHTML = "

Foo

"; + Idiomorph.morph( + foreignDocument, + "

Bar

", + ); + foreignDocument.body.innerHTML.should.equal("

Bar

"); + }); + it("morphs outerHTML as content properly when argument is HTMLElementCollection with siblings", function () { let parent = make("
"); let initial = parent.querySelector("button"); From 3482f665bdf064beddbdce6bf30196a280c4b9ec Mon Sep 17 00:00:00 2001 From: lizarusi Date: Thu, 13 Aug 2026 15:10:38 +0200 Subject: [PATCH 2/5] Recognize nodes from other realms via realm-safe type checks Nodes created in another JS realm (e.g. an iframe's document) fail `instanceof` checks, even after being adopted into this document, because each realm has its own constructors. This made morph() throw "TypeError: newContent is not iterable" when given a cross-realm node, and silently skip template handling, id-based matching, input/option/textarea value syncing, head handling, and Document normalization for cross-realm nodes. Replace realm-sensitive `instanceof` checks with helpers that duck-type via `nodeType` and `localName` (the approach morphdom uses). The `document.activeElement` checks keep `instanceof`, since the active element always belongs to this document. Each converted check has a dedicated test that fails if that single check is reverted to `instanceof`. --- src/idiomorph.js | 107 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 21 deletions(-) diff --git a/src/idiomorph.js b/src/idiomorph.js index bd3a69f..c5cb280 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -124,6 +124,80 @@ var Idiomorph = (function () { //============================================================================= const noOp = () => {}; + + //============================================================================= + // Realm-safe node type checks. + // + // `instanceof Node` (and friends) fails for nodes created in another JS + // realm (e.g. an iframe's document), even after they are adopted into this + // document, because each realm has its own constructors. These helpers + // duck-type nodes via `nodeType` and `localName` instead, so nodes from any + // realm are recognized. + //============================================================================= + + /** + * @param {any} value + * @returns {value is Node} + */ + function isNode(value) { + return typeof value?.nodeType === "number"; + } + + /** + * @param {any} value + * @returns {value is Element} + */ + function isElement(value) { + return value?.nodeType === Node.ELEMENT_NODE; + } + + /** + * @param {any} value + * @returns {value is Document} + */ + function isDocument(value) { + return value?.nodeType === Node.DOCUMENT_NODE; + } + + /** + * @param {any} value + * @returns {value is HTMLTemplateElement} + */ + function isTemplateElement(value) { + return isElement(value) && value.localName === "template"; + } + + /** + * @param {any} value + * @returns {value is HTMLHeadElement} + */ + function isHeadElement(value) { + return isElement(value) && value.localName === "head"; + } + + /** + * @param {any} value + * @returns {value is HTMLInputElement} + */ + function isInputElement(value) { + return isElement(value) && value.localName === "input"; + } + + /** + * @param {any} value + * @returns {value is HTMLOptionElement} + */ + function isOptionElement(value) { + return isElement(value) && value.localName === "option"; + } + + /** + * @param {any} value + * @returns {value is HTMLTextAreaElement} + */ + function isTextAreaElement(value) { + return isElement(value) && value.localName === "textarea"; + } /** * Default configuration values, updatable by users now * @type {ConfigInternal} @@ -286,10 +360,7 @@ var Idiomorph = (function () { endPoint = null, ) { // normalize - if ( - oldParent instanceof HTMLTemplateElement && - newParent instanceof HTMLTemplateElement - ) { + if (isTemplateElement(oldParent) && isTemplateElement(newParent)) { // @ts-ignore we can pretend the DocumentFragment is an Element oldParent = oldParent.content; // @ts-ignore ditto @@ -319,7 +390,7 @@ var Idiomorph = (function () { } // if the matching node is elsewhere in the original content - if (newChild instanceof Element) { + if (isElement(newChild)) { // we can pretend the id is non-null because the next `.has` line will reject it if not const newChildId = /** @type {String} */ ( newChild.getAttribute("id") @@ -647,12 +718,9 @@ var Idiomorph = (function () { return oldNode; } - if (oldNode instanceof HTMLHeadElement && ctx.head.style === "none") { + if (isHeadElement(oldNode) && ctx.head.style === "none") { // ignore the head element - } else if ( - oldNode instanceof HTMLHeadElement && - ctx.head.style !== "morph" - ) { + } else if (isHeadElement(oldNode) && ctx.head.style !== "morph") { // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above handleHeadElement( oldNode, @@ -742,8 +810,8 @@ var Idiomorph = (function () { */ function syncInputValue(oldElement, newElement, ctx) { if ( - oldElement instanceof HTMLInputElement && - newElement instanceof HTMLInputElement && + isInputElement(oldElement) && + isInputElement(newElement) && newElement.type !== "file" ) { let newValue = newElement.value; @@ -766,14 +834,11 @@ var Idiomorph = (function () { } // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? // did I break something? - } else if ( - oldElement instanceof HTMLOptionElement && - newElement instanceof HTMLOptionElement - ) { + } else if (isOptionElement(oldElement) && isOptionElement(newElement)) { syncBooleanAttribute(oldElement, newElement, "selected", ctx); } else if ( - oldElement instanceof HTMLTextAreaElement && - newElement instanceof HTMLTextAreaElement + isTextAreaElement(oldElement) && + isTextAreaElement(newElement) ) { let newValue = newElement.value; let oldValue = oldElement.value; @@ -1241,7 +1306,7 @@ var Idiomorph = (function () { * @returns {Element} */ function normalizeElement(content) { - if (content instanceof Document) { + if (isDocument(content)) { return content.documentElement; } else { // a Text or Comment node is not an Element, but morphOuterHTML only ever reads Node members off it @@ -1264,7 +1329,7 @@ var Idiomorph = (function () { ) { // the template tag created by idiomorph parsing can serve as a dummy parent return /** @type {Element} */ (newContent); - } else if (newContent instanceof Node) { + } else if (isNode(newContent)) { if (newContent.parentNode) { // we can't use the parent directly because newContent may have siblings // that we don't want in the morph, and reparenting might be expensive (TODO is it?), @@ -1324,7 +1389,7 @@ var Idiomorph = (function () { */ querySelectorAll(selector) { return this.childNodes.reduce((results, node) => { - if (node instanceof Element) { + if (isElement(node)) { if (node.matches(selector)) results.push(node); const nodeList = node.querySelectorAll(selector); for (let i = 0; i < nodeList.length; i++) { From 40d5f0989795f0a19d1278541f9d9d7369187fc4 Mon Sep 17 00:00:00 2001 From: lizarusi Date: Wed, 26 Aug 2026 13:43:10 +0200 Subject: [PATCH 3/5] Guard realm-safe element helpers by HTML namespace localName-only checks matched foreign-namespace elements (e.g. SVG