Skip to content

Commit

Permalink
Enforce read-only references
Browse files Browse the repository at this point in the history
  • Loading branch information
joeldrapper committed Feb 25, 2024
1 parent 7a67fa0 commit 1e1977f
Show file tree
Hide file tree
Showing 2 changed files with 49 additions and 24 deletions.
15 changes: 10 additions & 5 deletions dist/morphlex.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 39 additions & 19 deletions src/morphlex.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
type IdSet = Set<string>;
type IdMap = Map<Node, IdSet>;
type IdMap = Map<ReadOnlyNode<Node>, IdSet>;

interface ReadOnlyNodeInterface {
id: Element["id"];
tagName: Element["tagName"];
nodeType: Node["nodeType"];
localName: Element["localName"];
cloneNode: Node["cloneNode"];
childNodes: Node["childNodes"];
attributes: Element["attributes"];
hasAttribute: Element["hasAttribute"];
querySelectorAll: ParentNode["querySelectorAll"];
}

type ReadOnlyNode<T extends Node> = T | ReadOnlyNodeInterface;

export function morph(node: ChildNode, reference: ChildNode): void {
const readonlyReference = reference as ReadOnlyNode<ChildNode>;
const idMap: IdMap = new Map();

if (isParentNode(node) && isParentNode(reference)) {
if (isParentNode(node) && isParentNode(readonlyReference)) {
populateIdSets(node, idMap);
populateIdSets(reference, idMap);
populateIdSets(readonlyReference, idMap);
}

morphNodes(node, reference, idMap);
morphNodes(node, readonlyReference, idMap);
}

// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements.
function populateIdSets(node: ParentNode, idMap: IdMap): void {
function populateIdSets(node: ReadOnlyNode<ParentNode>, idMap: IdMap): void {
const elementsWithIds: NodeListOf<Element> = node.querySelectorAll("[id]");

for (const elementWithId of elementsWithIds) {
Expand All @@ -34,17 +49,17 @@ function populateIdSets(node: ParentNode, idMap: IdMap): void {
}

// This is where we actually morph the nodes. The `morph` function exists to set up the `idMap`.
function morphNodes(node: ChildNode, ref: ChildNode, idMap: IdMap): void {
function morphNodes(node: ChildNode, ref: ReadOnlyNode<ChildNode>, idMap: IdMap): void {
if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) {
// We need to check if the element is an input, option, or textarea here, because they have
// special attributes not covered by the isEqualNode check.
if (!isInput(node) && !isOption(node) && !isTextArea(node) && node.isEqualNode(ref)) return;
if (!isInput(node) && !isOption(node) && !isTextArea(node) && isEqualNode(node, ref)) return;
else {
if (node.hasAttributes() || ref.hasAttributes()) morphAttributes(node, ref);
if (node.hasChildNodes() || ref.hasChildNodes()) morphChildNodes(node, ref, idMap);
}
} else {
if (node.isEqualNode(ref)) return;
if (isEqualNode(node, ref)) return;
else if (isText(node) && isText(ref)) {
if (node.textContent !== ref.textContent) node.textContent = ref.textContent;
} else if (isComment(node) && isComment(ref)) {
Expand All @@ -53,7 +68,7 @@ function morphNodes(node: ChildNode, ref: ChildNode, idMap: IdMap): void {
}
}

function morphAttributes(elm: Element, ref: Element): void {
function morphAttributes(elm: Element, ref: ReadOnlyNode<Element>): void {
// Remove any excess attributes from the element that aren’t present in the reference.
for (const { name } of elm.attributes) ref.hasAttribute(name) || elm.removeAttribute(name);

Expand All @@ -79,7 +94,7 @@ function morphAttributes(elm: Element, ref: Element): void {
}

// Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match.
function morphChildNodes(element: Element, ref: Element, idMap: IdMap): void {
function morphChildNodes(element: Element, ref: ReadOnlyNode<Element>, idMap: IdMap): void {
const childNodes = [...element.childNodes];
const refChildNodes = [...ref.childNodes];

Expand All @@ -97,12 +112,12 @@ function morphChildNodes(element: Element, ref: Element, idMap: IdMap): void {
while (element.childNodes.length > ref.childNodes.length) element.lastChild?.remove();
}

function morphChildNode(child: ChildNode, ref: ChildNode, parent: Element, idMap: IdMap): void {
function morphChildNode(child: ChildNode, ref: ReadOnlyNode<ChildNode>, parent: Element, idMap: IdMap): void {
if (isElement(child) && isElement(ref)) morphChildElement(child, ref, parent, idMap);
else morphNodes(child, ref, idMap);
}

function morphChildElement(child: Element, ref: Element, parent: Element, idMap: IdMap): void {
function morphChildElement(child: Element, ref: ReadOnlyNode<Element>, parent: Element, idMap: IdMap): void {
const refIdSet = idMap.get(ref);

// Generate the array in advance of the loop
Expand Down Expand Up @@ -138,34 +153,39 @@ function morphChildElement(child: Element, ref: Element, parent: Element, idMap:
} else child.replaceWith(ref.cloneNode(true));
}

function isEqualNode(node: Node, ref: ReadOnlyNode<Node>): boolean {
const referenceNode = ref as Node;
return node.isEqualNode(referenceNode);
}

// We cannot use `instanceof` when nodes might be from different documents,
// so we use type guards instead. This keeps TypeScript happy, while doing
// the necessary checks at runtime.

function isText(node: Node): node is Text {
function isText(node: ReadOnlyNode<Node>): node is Text {
return node.nodeType === 3;
}

function isComment(node: Node): node is Comment {
function isComment(node: ReadOnlyNode<Node>): node is Comment {
return node.nodeType === 8;
}

function isElement(node: Node): node is Element {
function isElement(node: ReadOnlyNode<Node>): node is Element {
return node.nodeType === 1;
}

function isInput(element: Element): element is HTMLInputElement {
function isInput(element: ReadOnlyNode<Element>): element is HTMLInputElement {
return element.localName === "input";
}

function isOption(element: Element): element is HTMLOptionElement {
function isOption(element: ReadOnlyNode<Element>): element is HTMLOptionElement {
return element.localName === "option";
}

function isTextArea(element: Element): element is HTMLTextAreaElement {
function isTextArea(element: ReadOnlyNode<Element>): element is HTMLTextAreaElement {
return element.localName === "textarea";
}

function isParentNode(node: Node): node is ParentNode {
function isParentNode(node: ReadOnlyNode<Node>): node is ParentNode {
return node.nodeType === 1 || node.nodeType === 9 || node.nodeType === 11;
}

0 comments on commit 1e1977f

Please sign in to comment.