diff --git a/src/idiomorph.js b/src/idiomorph.js index bd3a69f..feed435 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -124,6 +124,98 @@ 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 {unknown} value + * @returns {value is Node} + */ + function isNode(value) { + return ( + value instanceof Node || + typeof (/** @type {any} */ (value)?.nodeType) === "number" + ); + } + + /** + * @param {Node | null | undefined} value + * @returns {value is Element} + */ + function isElement(value) { + return value instanceof Element || value?.nodeType === Node.ELEMENT_NODE; + } + + /** + * @param {Node | null | undefined} value + * @returns {value is Document} + */ + function isDocument(value) { + return value instanceof Document || value?.nodeType === Node.DOCUMENT_NODE; + } + + const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; + + /** + * @param {Node | null | undefined} value + * @param {string} localName + * @returns {value is Element} + */ + function isHtmlElement(value, localName) { + return ( + isElement(value) && + value.localName === localName && + value.namespaceURI === HTML_NAMESPACE + ); + } + + /** + * @param {Node | null | undefined} value + * @returns {value is HTMLTemplateElement} + */ + function isTemplateElement(value) { + return isHtmlElement(value, "template"); + } + + /** + * @param {Node | null | undefined} value + * @returns {value is HTMLHeadElement} + */ + function isHeadElement(value) { + return isHtmlElement(value, "head"); + } + + /** + * @param {Node | null | undefined} value + * @returns {value is HTMLInputElement} + */ + function isInputElement(value) { + return isHtmlElement(value, "input"); + } + + /** + * @param {Node | null | undefined} value + * @returns {value is HTMLOptionElement} + */ + function isOptionElement(value) { + return isHtmlElement(value, "option"); + } + + /** + * @param {Node | null | undefined} value + * @returns {value is HTMLTextAreaElement} + */ + function isTextAreaElement(value) { + return isHtmlElement(value, "textarea"); + } /** * Default configuration values, updatable by users now * @type {ConfigInternal} @@ -210,18 +302,15 @@ var Idiomorph = (function () { */ function saveAndRestoreFocus(ctx, fn) { if (!ctx.config.restoreFocus) return fn(); + // focus lives in the target's document, which may not be ours + const doc = ctx.target.ownerDocument; let activeElement = /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( - document.activeElement + doc.activeElement ); // don't bother if the active element is not an input or textarea - if ( - !( - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement - ) - ) { + if (!(isInputElement(activeElement) || isTextAreaElement(activeElement))) { return fn(); } @@ -231,7 +320,7 @@ var Idiomorph = (function () { if ( activeElementId && - activeElementId !== document.activeElement?.getAttribute("id") + activeElementId !== doc.activeElement?.getAttribute("id") ) { activeElement = ctx.target.querySelector( `[id="${CSS.escape(activeElementId)}"]`, @@ -286,10 +375,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 +405,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 +733,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 +825,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 +849,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 +1321,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 +1344,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 +1404,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++) { diff --git a/test/core.js b/test/core.js index 83f49e1..fef35cf 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,138 @@ describe("Core morphing tests", function () { initial.outerHTML.should.equal(""); }); + it("recognizes newContent as a node even when a child input shadows its properties", function () { + let parent = make(''); + let final = make( + '', + ); + Idiomorph.morph(parent, final, { morphStyle: "innerHTML" }); + parent.innerHTML.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
"; + 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"); diff --git a/test/restore-focus.js b/test/restore-focus.js index ed6e4c6..916af6b 100644 --- a/test/restore-focus.js +++ b/test/restore-focus.js @@ -21,6 +21,28 @@ describe("Option to forcibly restore focus after morph", function () { } describe("defaults to on", function () { + it("restores focus and selection state when morphing inside another document", function () { + let iframe = document.createElement("iframe"); + getWorkArea().append(iframe); + let foreignDocument = iframe.contentDocument; + foreignDocument.body.innerHTML = ``; + let input = foreignDocument.getElementById("focused"); + input.focus(); + input.setSelectionRange(1, 2); + foreignDocument.body.moveBefore = undefined; + + Idiomorph.morph( + foreignDocument.body, + ``, + { morphStyle: "innerHTML" }, + ); + + let focused = foreignDocument.getElementById("focused"); + (foreignDocument.activeElement === focused).should.equal(true); + focused.selectionStart.should.equal(1); + focused.selectionEnd.should.equal(2); + }); + it("restores focus and selection state with outerHTML morphStyle", function () { const div = make(`