From 29723306ee00f8bcc4a7c72daa26c281139e6bf4 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Sun, 25 Feb 2024 17:44:22 +0000 Subject: [PATCH] Enforce read-only references --- dist/morphlex.js | 12 +++--- src/morphlex.ts | 98 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 29 deletions(-) diff --git a/dist/morphlex.js b/dist/morphlex.js index a2ed0bc..b0b7194 100644 --- a/dist/morphlex.js +++ b/dist/morphlex.js @@ -1,10 +1,11 @@ export function morph(node, reference) { - const idMap = new Map(); - if (isParentNode(node) && isParentNode(reference)) { + const readonlyReference = reference; + const idMap = new WeakMap(); + 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, idMap) { @@ -109,9 +110,6 @@ function morphChildElement(child, ref, parent, idMap) { morphNodes(nextMatchByTagName, ref, idMap); } else child.replaceWith(ref.cloneNode(true)); } -// 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) { return node.nodeType === 3; } diff --git a/src/morphlex.ts b/src/morphlex.ts index b866002..66ffa9d 100644 --- a/src/morphlex.ts +++ b/src/morphlex.ts @@ -1,20 +1,60 @@ type IdSet = Set; -type IdMap = Map; +type IdMap = WeakMap, IdSet>; + +// This maps out the read-only interface that we use on reference nodes. +// Because this is used with an intersection type, it doesn’t matter that +// it includes properties that aren’t on all types of node. The important +// thing is it doesn’t include any setters or methods that could mutate +// the node. +interface ReadOnlyNodeInterface { + readonly attributes: Element["attributes"]; + readonly checked: HTMLInputElement["checked"]; + readonly childNodes: ParentNode["childNodes"] | ReadOnlyNodeList; + readonly cloneNode: Node["cloneNode"]; + readonly disabled: HTMLInputElement["disabled"]; + readonly hasAttribute: Element["hasAttribute"]; + readonly hasAttributes: Element["hasAttributes"]; + readonly hasChildNodes: ParentNode["hasChildNodes"]; + readonly id: Element["id"]; + readonly indeterminate: HTMLInputElement["indeterminate"]; + readonly localName: Element["localName"]; + readonly nodeType: Node["nodeType"]; + readonly nodeValue: Node["nodeValue"]; + readonly parentElement: ReadOnlyNodeInterface | Element["parentElement"]; + readonly querySelectorAll: (query: string) => NodeListOf | ReadOnlyNodeList; + readonly selected: HTMLOptionElement["selected"]; + readonly tagName: Element["tagName"]; + readonly textContent: Node["textContent"]; + readonly value: HTMLInputElement["value"]; +} + +// This interface for a read-only node list works to maintain the read-only-ness of +// associated nodes. See ReadOnlyNodeInterface[childNodes] for example. +interface ReadOnlyNodeList { + [Symbol.iterator](): IterableIterator>; + readonly [index: number]: T | ReadOnlyNodeInterface; + readonly length: NodeListOf["length"]; +} + +// This generic type sets up the intersection between the specific Node type +// and the read-only interface. +type ReadOnlyNode = T | ReadOnlyNodeInterface; export function morph(node: ChildNode, reference: ChildNode): void { - const idMap: IdMap = new Map(); + const readonlyReference = reference as ReadOnlyNode; + const idMap: IdMap = new WeakMap(); - 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 { - const elementsWithIds: NodeListOf = node.querySelectorAll("[id]"); +function populateIdSets(node: ReadOnlyNode, idMap: IdMap): void { + const elementsWithIds = node.querySelectorAll("[id]"); for (const elementWithId of elementsWithIds) { const id = elementWithId.id; @@ -22,7 +62,7 @@ function populateIdSets(node: ParentNode, idMap: IdMap): void { // Ignore empty IDs if (id === "") continue; - let current: Element | null = elementWithId; + let current: ReadOnlyNode | null = elementWithId; while (current) { const idSet: IdSet | undefined = idMap.get(current); @@ -34,17 +74,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, 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) && node.isEqualNode(ref as Node)) 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 (node.isEqualNode(ref as Node)) return; else if (isText(node) && isText(ref)) { if (node.textContent !== ref.textContent) node.textContent = ref.textContent; } else if (isComment(node) && isComment(ref)) { @@ -53,7 +93,7 @@ function morphNodes(node: ChildNode, ref: ChildNode, idMap: IdMap): void { } } -function morphAttributes(elm: Element, ref: Element): void { +function morphAttributes(elm: Element, ref: ReadOnlyNode): 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); @@ -79,7 +119,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, idMap: IdMap): void { const childNodes = [...element.childNodes]; const refChildNodes = [...ref.childNodes]; @@ -97,12 +137,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, 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, parent: Element, idMap: IdMap): void { const refIdSet = idMap.get(ref); // Generate the array in advance of the loop @@ -142,30 +182,44 @@ function morphChildElement(child: Element, ref: Element, parent: Element, idMap: // 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: Node): node is Text; +function isText(node: ReadOnlyNode): node is ReadOnlyNode; +function isText(node: Node | ReadOnlyNode): boolean { return node.nodeType === 3; } -function isComment(node: Node): node is Comment { +function isComment(node: Node): node is Comment; +function isComment(node: ReadOnlyNode): node is ReadOnlyNode; +function isComment(node: Node | ReadOnlyNode): boolean { return node.nodeType === 8; } -function isElement(node: Node): node is Element { +function isElement(node: Node): node is Element; +function isElement(node: ReadOnlyNode): node is ReadOnlyNode; +function isElement(node: Node | ReadOnlyNode): boolean { return node.nodeType === 1; } -function isInput(element: Element): element is HTMLInputElement { +function isInput(element: Element): element is HTMLInputElement; +function isInput(element: ReadOnlyNode): element is ReadOnlyNode; +function isInput(element: Element | ReadOnlyNode): boolean { return element.localName === "input"; } -function isOption(element: Element): element is HTMLOptionElement { +function isOption(element: Element): element is HTMLOptionElement; +function isOption(element: ReadOnlyNode): element is ReadOnlyNode; +function isOption(element: Element | ReadOnlyNode): boolean { return element.localName === "option"; } -function isTextArea(element: Element): element is HTMLTextAreaElement { +function isTextArea(element: Element): element is HTMLTextAreaElement; +function isTextArea(element: ReadOnlyNode): element is ReadOnlyNode; +function isTextArea(element: Element | ReadOnlyNode): boolean { return element.localName === "textarea"; } -function isParentNode(node: Node): node is ParentNode { +function isParentNode(node: Node): node is ParentNode; +function isParentNode(node: ReadOnlyNode): node is ReadOnlyNode; +function isParentNode(node: Node | ReadOnlyNode): boolean { return node.nodeType === 1 || node.nodeType === 9 || node.nodeType === 11; }