Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 109 additions & 29 deletions src/idiomorph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Comment thread
lizarusi marked this conversation as resolved.

/**
* @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");
}
Comment thread
lizarusi marked this conversation as resolved.
/**
* Default configuration values, updatable by users now
* @type {ConfigInternal}
Expand Down Expand Up @@ -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();
}

Expand All @@ -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)}"]`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 <head>, it would've got caught in the `!isSoftMatch` branch above
handleHeadElement(
oldNode,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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?),
Expand Down Expand Up @@ -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++) {
Expand Down
139 changes: 139 additions & 0 deletions test/core.js
Original file line number Diff line number Diff line change
@@ -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("<button>Foo</button>");
Expand Down Expand Up @@ -60,6 +67,138 @@ describe("Core morphing tests", function () {
initial.outerHTML.should.equal("<button>Bar</button>");
});

it("recognizes newContent as a node even when a child input shadows its properties", function () {
let parent = make('<div><form><input name="nodeType"></form></div>');
let final = make(
'<form><input name="nodeType"><input name="other"></form>',
);
Idiomorph.morph(parent, final, { morphStyle: "innerHTML" });
parent.innerHTML.should.equal(
'<form><input name="nodeType"><input name="other"></form>',
);
});

it("morphs outerHTML as content properly when argument is a node from another document", function () {
let initial = make("<button>Foo</button>");
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("<button>Bar</button>");
});

it("syncs input value when newContent is a node from another document", function () {
let initial = make('<input type="text">');
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(`<div><span id="s">Foo</span></div>`);
let span = initial.querySelector("span");
let final = makeForeign(`<div><div><span id="s">Foo</span></div></div>`);
final.remove();
Idiomorph.morph(initial, final);
initial.outerHTML.should.equal(
`<div><div><span id="s">Foo</span></div></div>`,
);
initial.querySelector("span").should.equal(span);
});

it("preserves id'd element when attached newContent is from another document", function () {
let initial = make(`<div><span id="s">Foo</span></div>`);
let span = initial.querySelector("span");
let final = makeForeign(`<div><div><span id="s">Foo</span></div></div>`);

Idiomorph.morph(initial, final);
initial.outerHTML.should.equal(
`<div><div><span id="s">Foo</span></div></div>`,
);
initial.querySelector("span").should.equal(span);
});

it("morphs template tag contents when newContent is from another document", function () {
let initial = make("<template data-old>Foo</template>");
let final = makeForeign("<template data-new>Bar</template>");
Idiomorph.morph(initial, final);
initial.outerHTML.should.equal('<template data-new="">Bar</template>');
});

it("syncs option selectedness when newContent is from another document", function () {
let parent = make(
`<div><select><option>0</option><option>1</option></select></div>`,
);
getWorkArea().append(parent);
let select = parent.querySelector("select");
let final = makeForeign(
`<select><option>0</option><option>1</option></select>`,
);
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("<textarea>Foo</textarea>");
initial.value = "dirty";
let final = makeForeign("<textarea>Bar</textarea>");
Idiomorph.morph(initial, final);
initial.value.should.equal("Bar");
});

it("morphs children of an SVG-namespace template element normally", function () {
let initial = make("<div><svg><template><g></g></template></svg></div>");
let finalSrc = "<div><svg><template><rect></rect></template></svg></div>";
Idiomorph.morph(initial, finalSrc);
initial.outerHTML.should.equal(finalSrc);
});

it("morphs non-HTML-namespace head elements normally", function () {
let parse = (str) =>
new DOMParser().parseFromString(str, "text/xml").documentElement;
let initial = parse(
'<opml><head lang="a"><title>Old</title></head></opml>',
);
let final = parse('<opml><head lang="b"><title>New</title></head></opml>');
Idiomorph.morph(initial.firstChild, final.firstChild, {
morphStyle: "outerHTML",
});
initial.firstChild.getAttribute("lang").should.equal("b");
initial.firstChild.firstChild.textContent.should.equal("New");
});

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 = "<title>New</title>";
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 = "<p>Foo</p>";
Idiomorph.morph(
foreignDocument,
"<html><head></head><body><p>Bar</p></body></html>",
);
foreignDocument.body.innerHTML.should.equal("<p>Bar</p>");
});

it("morphs outerHTML as content properly when argument is HTMLElementCollection with siblings", function () {
let parent = make("<div><button>Foo</button></div>");
let initial = parent.querySelector("button");
Expand Down
Loading
Loading