diff --git a/src/idiomorph.js b/src/idiomorph.js index bbd972e..92f536e 100644 --- a/src/idiomorph.js +++ b/src/idiomorph.js @@ -231,8 +231,8 @@ var Idiomorph = (function () { activeElementId && activeElementId !== ctx.doc.activeElement?.getAttribute("id") ) { - activeElement = ctx.target.querySelector( - `[id="${CSS.escape(activeElementId)}"]`, + activeElement = /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( + querySelectorFrom(ctx.target, `[id="${CSS.escape(activeElementId)}"]`) ); activeElement?.focus(); } @@ -316,9 +316,7 @@ var Idiomorph = (function () { // if the matching node is elsewhere in the original content if (is.element(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") - ); + const newChildId = /** @type {String} */ (idAttributeOf(newChild)); if (ctx.persistentIds.has(newChildId)) { // move it and all its children here and morph const movedChild = moveBeforeById( @@ -494,8 +492,8 @@ var Idiomorph = (function () { // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, // its not persistent, and new nodes can't have any hidden state. // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment - (!oldElt.getAttribute?.("id") || - oldElt.getAttribute?.("id") === newElt.getAttribute?.("id")) + (!idAttributeOf(oldElt) || + idAttributeOf(oldElt) === idAttributeOf(newElt)) ); } @@ -564,8 +562,8 @@ var Idiomorph = (function () { ( // ctx.target.id unsafe because of form input shadowing // ctx.target could be a document fragment which doesn't have `getAttribute` - (ctx.target.getAttribute?.("id") === id && ctx.target) || - ctx.target.querySelector(selector) || + (idAttributeOf(ctx.target) === id && ctx.target) || + querySelectorFrom(ctx.target, selector) || ctx.pantry.querySelector(selector) ); removeElementFromAncestorsIdMaps(target, ctx); @@ -583,7 +581,7 @@ var Idiomorph = (function () { */ function removeElementFromAncestorsIdMaps(element, ctx) { // we know id is non-null String, because this function is only called on elements with ids - const id = /** @type {String} */ (element.getAttribute("id")); + const id = /** @type {String} */ (idAttributeOf(element)); /** @ts-ignore - safe to loop in this way **/ while ((element = element.parentNode)) { let idSet = ctx.idMap.get(element); @@ -1019,7 +1017,7 @@ var Idiomorph = (function () { throw `Do not understand how to morph head style ${headStyle}`; } - const doc = oldNode.ownerDocument; + const doc = ownerDocumentOf(oldNode); return { target: oldNode, @@ -1104,15 +1102,23 @@ var Idiomorph = (function () { function findIdElements(root) { /** @type {IdElement[]} */ let elements = []; - // root could be a text or comment node which has no `querySelectorAll`, - // or a document fragment which has no `getAttribute` + // an Element's own `querySelectorAll` is unsafe because of form input + // shadowing, so it is called off the prototype; the root could also be a + // text or comment node, which genuinely has none, or a document or + // fragment, which are not subject to form named-property shadowing const rootElt = /** @type {Partial} */ (root); - for (const elt of rootElt.querySelectorAll?.("[id]") ?? []) { + const idElts = is.element(root) + ? (querySelectorAll ??= Element.prototype.querySelectorAll).call( + root, + "[id]", + ) + : (rootElt.querySelectorAll?.("[id]") ?? []); + for (const elt of idElts) { // elt.id is unsafe because of form input shadowing, and `id=""` is not persistable - const id = elt.getAttribute("id"); + const id = idAttributeOf(elt); if (id) elements.push({ elt, id }); } - const rootId = rootElt.getAttribute?.("id"); + const rootId = idAttributeOf(root); if (rootId) elements.push({ elt: /** @type {Element} */ (root), id: rootId }); return elements; @@ -1415,6 +1421,15 @@ var Idiomorph = (function () { //============================================================================= // 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 fall + * back to duck-typing via `nodeType` and `localName`, so nodes from any realm + * are recognized. `instanceof` is still tried first, because duck-typing is + * itself shadowable: a `
` is [LegacyOverrideBuiltIns], so a control + * named `nodeType` installs an own property that hides the real one. + */ const is = (function () { /** @param {Node | null | undefined} value @returns {value is Element} */ const element = (value) => @@ -1452,6 +1467,62 @@ var Idiomorph = (function () { }; })(); + /** + * `` is [LegacyOverrideBuiltIns], so a named control such as + * `` installs an own property on the form that + * shadows `Node.prototype.ownerDocument`. Read through the prototype getter + * instead, which is also realm-safe: it brand-checks the internal slot + * rather than the realm the node was created in. + */ + /** @type {((this: Node) => Document) | undefined} */ + let ownerDocumentGetter; + + /** @param {Node} node @returns {Document} */ + const ownerDocumentOf = (node) => { + // Resolve lazily so importing the module does not require a DOM. + ownerDocumentGetter ??= /** @type {(this: Node) => Document} */ ( + /** @type {PropertyDescriptor} */ ( + Object.getOwnPropertyDescriptor(Node.prototype, "ownerDocument") + ).get + ); + return ownerDocumentGetter.call(node); + }; + + /** + * `` shadowing applies to methods too: a control named + * `querySelector`, `querySelectorAll` or `getAttribute` hides the real method. + * Read these off `Element.prototype` on first use. They are realm-safe for the + * same reason the `ownerDocument` getter is. + */ + /** @type {Element['querySelectorAll'] | undefined} */ + let querySelectorAll; + /** @type {Element['querySelector'] | undefined} */ + let querySelector; + /** @type {Element['getAttribute'] | undefined} */ + let getAttribute; + + /** + * @param {Element | DocumentFragment} root + * @param {string} selector + * @returns {Element | null} + */ + const querySelectorFrom = (root, selector) => + is.element(root) + ? (querySelector ??= Element.prototype.querySelector).call(root, selector) + : root.querySelector(selector); + + /** + * Reads a node's `id` attribute. `node.id` is shadowed by a form control + * named `id`, and `node.getAttribute` by one named `getAttribute`; the node + * may also be a text, comment or fragment node, which has no id at all. + * @param {Node} node + * @returns {string | null} + */ + const idAttributeOf = (node) => + is.element(node) + ? (getAttribute ??= Element.prototype.getAttribute).call(node, "id") + : null; + //============================================================================= // This is what ends up becoming the Idiomorph global object //============================================================================= diff --git a/test/core.js b/test/core.js index d543b5f..c8ecaee 100644 --- a/test/core.js +++ b/test/core.js @@ -1,4 +1,70 @@ describe("Core morphing tests", function () { + it("restores focus when the target form shadows querySelector", function () { + let form = make( + `
`, + ); + getWorkArea().append(form); + let input = form.querySelectorAll("#focused")[0]; + input.parentElement.moveBefore = undefined; + form.querySelectorAll("#right")[0].moveBefore = undefined; + input.focus(); + (document.activeElement === input).should.equal(true); + input.setSelectionRange(1, 2); + Idiomorph.morph( + form, + `
`, + { morphStyle: "innerHTML" }, + ); + (document.activeElement === input).should.equal(true); + input.selectionStart.should.equal(1); + input.selectionEnd.should.equal(2); + }); + + it("moves an IDed descendant when the target form shadows querySelector", function () { + let form = make( + `
Old
`, + ); + let span = form.querySelectorAll("span")[0]; + Idiomorph.morph( + form, + `
`, + { morphStyle: "innerHTML" }, + ); + (form.querySelectorAll("#right > span")[0] === span).should.equal(true); + span.textContent.should.equal("New"); + }); + + for (const shadowedSide of ["old", "new"]) { + it(`moves an IDed form when the ${shadowedSide} form shadows getAttribute`, function () { + let control = ''; + let root = make( + `
${shadowedSide === "old" ? control : ""}
`, + ); + let form = root.querySelector("form"); + Idiomorph.morph( + root, + `
`, + { + morphStyle: "innerHTML", + // Exercise ID movement without entering the separate attribute-sync path. + callbacks: { beforeNodeMorphed: (node) => node !== form }, + }, + ); + (root.querySelector("#right > form") === form).should.equal(true); + root.querySelector("#left").children.length.should.equal(0); + }); + } + + it("can load before DOM constructors are available", async function () { + const source = await (await fetch("/src/idiomorph.js")).text(); + const load = new Function( + "Node", + "Element", + source + "; return Idiomorph;", + ); + load(undefined, undefined).morph.should.be.a("function"); + }); + setup(); it("morphs outerHTML by default", function () { let initial = make(""); @@ -312,6 +378,32 @@ describe("Core morphing tests", function () { getWorkArea().querySelector("form").should.equal(form); }); + it("can morph a form root whose control shadows querySelectorAll", function () { + let initial = make( + `
Foo
`, + ); + getWorkArea().append(initial); + Idiomorph.morph( + initial, + `
Bar
`, + ); + initial.querySelector("span").innerHTML.should.equal("Bar"); + }); + + it("can morph children of a form whose control shadows getAttribute", function () { + let initial = make( + `
Foo
`, + ); + let span = initial.querySelector("span"); + Idiomorph.morph( + initial, + `Bar`, + { morphStyle: "innerHTML" }, + ); + (initial.querySelector("span") === span).should.equal(true); + span.innerHTML.should.equal("Bar"); + }); + it("ignores active element when ignoreActive set to true", function () { let initialSource = "
Foo
"; getWorkArea().innerHTML = initialSource; diff --git a/test/realm.js b/test/realm.js index c077987..99569d8 100644 --- a/test/realm.js +++ b/test/realm.js @@ -261,6 +261,7 @@ describe("Cross-realm morphing tests", function () { { ignoreActiveValue: true }, ); input.value.should.equal("user typed"); + input.getAttribute("value").should.equal("server"); }); it("honors ignoreActive for the target document's active element", function () { @@ -276,4 +277,36 @@ describe("Cross-realm morphing tests", function () { ); input.className.should.equal("old"); }); + + it("ignores the active textarea's value in the target's document when ignoreActiveValue is set", function () { + let doc = makeIframe().contentDocument; + doc.body.innerHTML = ""; + let t1 = doc.getElementById("t1"); + t1.focus(); + t1.value = "typed-by-user"; + (doc.activeElement === t1).should.equal(true); + Idiomorph.morph(t1, "", { + morphStyle: "outerHTML", + ignoreActiveValue: true, + }); + t1.value.should.equal("typed-by-user"); + t1.classList.value.should.equal("c"); + }); + + it("preserves focus algorithmically when morphing inside another document", function () { + let doc = makeIframe().contentDocument; + doc.body.innerHTML = `
`; + let focused = doc.getElementById("focused"); + focused.parentElement.moveBefore = undefined; + focused.focus(); + (doc.activeElement === focused).should.equal(true); + + Idiomorph.morph( + doc.body, + `
`, + { morphStyle: "innerHTML", restoreFocus: false }, + ); + + (doc.activeElement === focused).should.equal(true); + }); }); diff --git a/test/restore-focus.js b/test/restore-focus.js index 916af6b..4811a96 100644 --- a/test/restore-focus.js +++ b/test/restore-focus.js @@ -43,6 +43,35 @@ describe("Option to forcibly restore focus after morph", function () { focused.selectionEnd.should.equal(2); }); + it("restores focus and selection state when the target form shadows ownerDocument", function () { + // a
is [LegacyOverrideBuiltIns]: a named control installs an own + // property that shadows Node.prototype.ownerDocument + let form = make(`
`); + getWorkArea().append(form); + form.innerHTML = ` +
+ `; + for (const elt of form.querySelectorAll("input")) { + elt.parentElement.moveBefore = undefined; + } + let input = document.getElementById("focused"); + input.focus(); + input.setSelectionRange(1, 2); + + Idiomorph.morph( + form, + ` +
+ `, + { morphStyle: "innerHTML" }, + ); + + let focused = document.getElementById("focused"); + (document.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(`