Skip to content
Closed
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
103 changes: 87 additions & 16 deletions src/idiomorph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))
);
}

Expand Down Expand Up @@ -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)
);
Comment on lines +565 to 568
removeElementFromAncestorsIdMaps(target, ctx);
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Element>} */ (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;
Expand Down Expand Up @@ -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 `<form>` 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) =>
Expand Down Expand Up @@ -1452,6 +1467,62 @@ var Idiomorph = (function () {
};
})();

/**
* `<form>` is [LegacyOverrideBuiltIns], so a named control such as
* `<input name="ownerDocument">` 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);
};

/**
* `<form>` 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
//=============================================================================
Expand Down
92 changes: 92 additions & 0 deletions test/core.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,70 @@
describe("Core morphing tests", function () {
it("restores focus when the target form shadows querySelector", function () {
let form = make(
`<form><input name="querySelector"><div id="left"><input id="focused" value="abc"></div><div id="right"></div></form>`,
);
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,
`<input name="querySelector"><div id="left"></div><div id="right"><input id="focused" value="abc"></div>`,
{ 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(
`<form><input name="querySelector"><div id="left"><span id="s">Old</span></div><div id="right"></div></form>`,
);
let span = form.querySelectorAll("span")[0];
Idiomorph.morph(
form,
`<input name="querySelector"><div id="left"></div><div id="right"><span id="s">New</span></div>`,
{ 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 = '<input name="getAttribute">';
let root = make(
`<div><section id="left"><form id="f">${shadowedSide === "old" ? control : ""}</form></section><section id="right"></section></div>`,
);
let form = root.querySelector("form");
Idiomorph.morph(
root,
`<section id="left"></section><section id="right"><form id="f">${shadowedSide === "new" ? control : ""}</form></section>`,
{
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("<button>Foo</button>");
Expand Down Expand Up @@ -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(
`<form id="f"><input name="querySelectorAll"><span id="s">Foo</span></form>`,
);
getWorkArea().append(initial);
Idiomorph.morph(
initial,
`<form id="f"><input name="querySelectorAll"><span id="s">Bar</span></form>`,
);
initial.querySelector("span").innerHTML.should.equal("Bar");
});

it("can morph children of a form whose control shadows getAttribute", function () {
let initial = make(
`<form id="f"><input name="getAttribute"><span id="s">Foo</span></form>`,
);
let span = initial.querySelector("span");
Idiomorph.morph(
initial,
`<input name="getAttribute"><span id="s">Bar</span>`,
{ 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 = "<div><div id='d1'>Foo</div><input id='i1'></div>";
getWorkArea().innerHTML = initialSource;
Expand Down
33 changes: 33 additions & 0 deletions test/realm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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 = "<textarea id='t1'>server1</textarea>";
let t1 = doc.getElementById("t1");
t1.focus();
t1.value = "typed-by-user";
(doc.activeElement === t1).should.equal(true);
Idiomorph.morph(t1, "<textarea id='t1' class='c'>server2</textarea>", {
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 = `<div><input type="text" id="focused" value="abc"><input type="text" id="other"></div>`;
let focused = doc.getElementById("focused");
focused.parentElement.moveBefore = undefined;
focused.focus();
(doc.activeElement === focused).should.equal(true);

Idiomorph.morph(
doc.body,
`<div><input type="text" id="other"><input type="text" id="focused" value="abc"></div>`,
{ morphStyle: "innerHTML", restoreFocus: false },
);

(doc.activeElement === focused).should.equal(true);
});
});
29 changes: 29 additions & 0 deletions test/restore-focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <form> is [LegacyOverrideBuiltIns]: a named control installs an own
// property that shadows Node.prototype.ownerDocument
let form = make(`<form></form>`);
getWorkArea().append(form);
form.innerHTML = `<input name="ownerDocument">
<div id="left"><input type="text" id="focused" value="abc"></div>
<div id="right"><input type="text" id="other"></div>`;
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,
`<input name="ownerDocument">
<div id="left"><input type="text" id="other"></div>
<div id="right"><input type="text" id="focused" value="abc"></div>`,
{ 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(`
<div>
Expand Down
Loading