Precise DOM morphing
morphing typescript dom

Remove sensitivity map

+368 -682
+1 -7
README.md
··· 4 4 5 5 Morphlex is a tiny, optimal DOM morphing library written in TypeScript. DOM morphing is the process of transforming one DOM tree to reflect another, while preserving the state of the original tree and making as few changes as possible. 6 6 7 - Morphlex uses ID Sets — a concept pioneered by [Idiomorph](https://github.com/bigskysoftware/idiomorph) — to match nodes with deeply nested identified elements. It also maps out _sensitive_ elements to avoid moving them around. 7 + Morphlex uses ID Sets — a concept pioneered by [Idiomorph](https://github.com/bigskysoftware/idiomorph) — to match nodes with deeply nested identified elements. 8 8 9 9 ## ID Sets 10 10 11 11 Each element is tagged with the set of IDs it contains, allowing for more optimal matching. 12 12 13 13 Failing an ID Set match, Morphlex will search for the next best match by tag name. If no element can be found, the reference element will be deeply cloned instead. 14 - 15 - ## Node sensitivity 16 - 17 - Simply moving certain elements in the DOM tree can cause issues. To account for this, Morphlex gives priority to sensitive elements, moving less sensitive elements around them whenever possible. 18 - 19 - This works in any direction, even if the sensitive element is deeply nested. 20 14 21 15 ## Try it out 22 16
+17
dist/morphlex.d.ts
··· 1 + interface Options { 2 + ignoreActiveValue?: boolean; 3 + preserveModifiedValues?: boolean; 4 + beforeNodeMorphed?: (node: Node, referenceNode: Node) => boolean; 5 + afterNodeMorphed?: (node: Node, referenceNode: Node) => void; 6 + beforeNodeAdded?: (node: Node) => boolean; 7 + afterNodeAdded?: (node: Node) => void; 8 + beforeNodeRemoved?: (node: Node) => boolean; 9 + afterNodeRemoved?: (node: Node) => void; 10 + beforeAttributeUpdated?: (element: Element, attributeName: string, newValue: string | null) => boolean; 11 + afterAttributeUpdated?: (element: Element, attributeName: string, previousValue: string | null) => void; 12 + beforePropertyUpdated?: (node: Node, propertyName: PropertyKey, newValue: unknown) => boolean; 13 + afterPropertyUpdated?: (node: Node, propertyName: PropertyKey, previousValue: unknown) => void; 14 + } 15 + export declare function morph(node: ChildNode, reference: ChildNode | string, options?: Options): void; 16 + export declare function morphInner(element: Element, reference: Element | string, options?: Options): void; 17 + export {};
+349
dist/morphlex.js
··· 1 + export function morph(node, reference, options = {}) { 2 + if (typeof reference === "string") 3 + reference = parseChildNodeFromString(reference); 4 + new Morph(options).morph([node, reference]); 5 + } 6 + export function morphInner(element, reference, options = {}) { 7 + if (typeof reference === "string") 8 + reference = parseElementFromString(reference); 9 + new Morph(options).morphInner([element, reference]); 10 + } 11 + function parseElementFromString(string) { 12 + const node = parseChildNodeFromString(string); 13 + if (isElement(node)) 14 + return node; 15 + else 16 + throw new Error("[Morphlex] The string was not a valid HTML element."); 17 + } 18 + function parseChildNodeFromString(string) { 19 + const parser = new DOMParser(); 20 + const doc = parser.parseFromString(string, "text/html"); 21 + if (doc.childNodes.length === 1) 22 + return doc.body.firstChild; 23 + else 24 + throw new Error("[Morphlex] The string was not a valid HTML node."); 25 + } 26 + class Morph { 27 + #idMap; 28 + #ignoreActiveValue; 29 + #preserveModifiedValues; 30 + #beforeNodeMorphed; 31 + #afterNodeMorphed; 32 + #beforeNodeAdded; 33 + #afterNodeAdded; 34 + #beforeNodeRemoved; 35 + #afterNodeRemoved; 36 + #beforeAttributeUpdated; 37 + #afterAttributeUpdated; 38 + #beforePropertyUpdated; 39 + #afterPropertyUpdated; 40 + constructor(options = {}) { 41 + this.#idMap = new WeakMap(); 42 + this.#ignoreActiveValue = options.ignoreActiveValue || false; 43 + this.#preserveModifiedValues = options.preserveModifiedValues || false; 44 + this.#beforeNodeMorphed = options.beforeNodeMorphed; 45 + this.#afterNodeMorphed = options.afterNodeMorphed; 46 + this.#beforeNodeAdded = options.beforeNodeAdded; 47 + this.#afterNodeAdded = options.afterNodeAdded; 48 + this.#beforeNodeRemoved = options.beforeNodeRemoved; 49 + this.#afterNodeRemoved = options.afterNodeRemoved; 50 + this.#beforeAttributeUpdated = options.beforeAttributeUpdated; 51 + this.#afterAttributeUpdated = options.afterAttributeUpdated; 52 + this.#beforePropertyUpdated = options.beforePropertyUpdated; 53 + this.#afterPropertyUpdated = options.afterPropertyUpdated; 54 + } 55 + morph(pair) { 56 + this.#withAriaBusy(pair[0], () => { 57 + if (isParentNodePair(pair)) 58 + this.#buildMaps(pair); 59 + this.#morphNode(pair); 60 + }); 61 + } 62 + morphInner(pair) { 63 + this.#withAriaBusy(pair[0], () => { 64 + if (isMatchingElementPair(pair)) { 65 + this.#buildMaps(pair); 66 + this.#morphMatchingElementContent(pair); 67 + } 68 + else { 69 + throw new Error("[Morphlex] You can only do an inner morph with matching elements."); 70 + } 71 + }); 72 + } 73 + #withAriaBusy(node, block) { 74 + if (isElement(node)) { 75 + const originalAriaBusy = node.ariaBusy; 76 + node.ariaBusy = "true"; 77 + block(); 78 + node.ariaBusy = originalAriaBusy; 79 + } 80 + else 81 + block(); 82 + } 83 + #buildMaps([node, reference]) { 84 + this.#mapIdSets(node); 85 + this.#mapIdSets(reference); 86 + } 87 + // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 88 + #mapIdSets(node) { 89 + const elementsWithIds = node.querySelectorAll("[id]"); 90 + const elementsWithIdsLength = elementsWithIds.length; 91 + for (let i = 0; i < elementsWithIdsLength; i++) { 92 + const elementWithId = elementsWithIds[i]; 93 + const id = elementWithId.id; 94 + // Ignore empty IDs 95 + if (id === "") 96 + continue; 97 + let current = elementWithId; 98 + while (current) { 99 + const idSet = this.#idMap.get(current); 100 + idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id])); 101 + if (current === node) 102 + break; 103 + current = current.parentElement; 104 + } 105 + } 106 + } 107 + // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 108 + #morphNode(pair) { 109 + if (isMatchingElementPair(pair)) 110 + this.#morphMatchingElementNode(pair); 111 + else 112 + this.#morphOtherNode(pair); 113 + } 114 + #morphMatchingElementNode(pair) { 115 + const [node, reference] = pair; 116 + if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) 117 + return; 118 + if (node.hasAttributes() || reference.hasAttributes()) 119 + this.#morphAttributes(pair); 120 + // TODO: Should use a branded pair here. 121 + this.#morphMatchingElementContent(pair); 122 + this.#afterNodeMorphed?.(node, writableNode(reference)); 123 + } 124 + #morphOtherNode([node, reference]) { 125 + if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) 126 + return; 127 + if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) { 128 + // Handle text nodes, comments, and CDATA sections. 129 + this.#updateProperty(node, "nodeValue", reference.nodeValue); 130 + } 131 + else 132 + this.#replaceNode(node, reference.cloneNode(true)); 133 + this.#afterNodeMorphed?.(node, writableNode(reference)); 134 + } 135 + #morphMatchingElementContent(pair) { 136 + const [node, reference] = pair; 137 + if (isHead(node)) { 138 + // We can pass the reference as a head here becuase we know it's the same as the node. 139 + this.#morphHeadContents(pair); 140 + } 141 + else if (node.hasChildNodes() || reference.hasChildNodes()) 142 + this.#morphChildNodes(pair); 143 + } 144 + #morphHeadContents([node, reference]) { 145 + const refChildNodesMap = new Map(); 146 + // Generate a map of the reference head element’s child nodes, keyed by their outerHTML. 147 + const referenceChildrenLength = reference.children.length; 148 + for (let i = 0; i < referenceChildrenLength; i++) { 149 + const child = reference.children[i]; 150 + refChildNodesMap.set(child.outerHTML, child); 151 + } 152 + // Iterate backwards to safely remove children without affecting indices 153 + for (let i = node.children.length - 1; i >= 0; i--) { 154 + const child = node.children[i]; 155 + const key = child.outerHTML; 156 + const refChild = refChildNodesMap.get(key); 157 + // If the child is in the reference map already, we don't need to add it later. 158 + // If it's not in the map, we need to remove it from the node. 159 + refChild ? refChildNodesMap.delete(key) : this.#removeNode(child); 160 + } 161 + // Any remaining nodes in the map should be appended to the head. 162 + for (const refChild of refChildNodesMap.values()) 163 + this.#appendChild(node, refChild.cloneNode(true)); 164 + } 165 + #morphAttributes([element, reference]) { 166 + // Remove any excess attributes from the element that aren’t present in the reference. 167 + for (const { name, value } of element.attributes) { 168 + if (!reference.hasAttribute(name) && (this.#beforeAttributeUpdated?.(element, name, null) ?? true)) { 169 + element.removeAttribute(name); 170 + this.#afterAttributeUpdated?.(element, name, value); 171 + } 172 + } 173 + // Copy attributes from the reference to the element, if they don’t already match. 174 + for (const { name, value } of reference.attributes) { 175 + const previousValue = element.getAttribute(name); 176 + if (previousValue !== value && (this.#beforeAttributeUpdated?.(element, name, value) ?? true)) { 177 + element.setAttribute(name, value); 178 + this.#afterAttributeUpdated?.(element, name, previousValue); 179 + } 180 + } 181 + // For certain types of elements, we need to do some extra work to ensure 182 + // the element’s state matches the reference elements’ state. 183 + if (isInput(element) && isInput(reference)) { 184 + this.#updateProperty(element, "checked", reference.checked); 185 + this.#updateProperty(element, "disabled", reference.disabled); 186 + this.#updateProperty(element, "indeterminate", reference.indeterminate); 187 + if (element.type !== "file" && 188 + !(this.#ignoreActiveValue && document.activeElement === element) && 189 + !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)) { 190 + this.#updateProperty(element, "value", reference.value); 191 + } 192 + } 193 + else if (isOption(element) && isOption(reference)) { 194 + this.#updateProperty(element, "selected", reference.selected); 195 + } 196 + else if (isTextArea(element) && 197 + isTextArea(reference) && 198 + !(this.#ignoreActiveValue && document.activeElement === element) && 199 + !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)) { 200 + this.#updateProperty(element, "value", reference.value); 201 + const text = element.firstElementChild; 202 + if (text) 203 + this.#updateProperty(text, "textContent", reference.value); 204 + } 205 + } 206 + // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 207 + #morphChildNodes(pair) { 208 + const [element, reference] = pair; 209 + const childNodes = element.childNodes; 210 + const refChildNodes = reference.childNodes; 211 + for (let i = 0; i < refChildNodes.length; i++) { 212 + const child = childNodes[i]; 213 + const refChild = refChildNodes[i]; 214 + if (child && refChild) { 215 + const pair = [child, refChild]; 216 + if (isMatchingElementPair(pair)) { 217 + if (isHead(pair[0])) { 218 + this.#morphHeadContents(pair); 219 + } 220 + else { 221 + this.#morphChildElement(pair, element); 222 + } 223 + } 224 + else 225 + this.#morphOtherNode(pair); 226 + } 227 + else if (refChild) { 228 + this.#appendChild(element, refChild.cloneNode(true)); 229 + } 230 + else if (child) { 231 + this.#removeNode(child); 232 + } 233 + } 234 + // Clean up any excess nodes that may be left over 235 + while (childNodes.length > refChildNodes.length) { 236 + const child = element.lastChild; 237 + if (child) 238 + this.#removeNode(child); 239 + } 240 + } 241 + #morphChildElement([child, reference], parent) { 242 + if (!(this.#beforeNodeMorphed?.(child, writableNode(reference)) ?? true)) 243 + return; 244 + const refIdSet = this.#idMap.get(reference); 245 + // Generate the array in advance of the loop 246 + const refSetArray = refIdSet ? [...refIdSet] : []; 247 + let currentNode = child; 248 + let nextMatchByTagName = null; 249 + // Try find a match by idSet, while also looking out for the next best match by tagName. 250 + while (currentNode) { 251 + if (isElement(currentNode)) { 252 + const id = currentNode.id; 253 + if (!nextMatchByTagName && currentNode.localName === reference.localName) { 254 + nextMatchByTagName = currentNode; 255 + } 256 + if (id !== "") { 257 + if (id === reference.id) { 258 + this.#insertBefore(parent, currentNode, child); 259 + return this.#morphNode([currentNode, reference]); 260 + } 261 + else { 262 + const currentIdSet = this.#idMap.get(currentNode); 263 + if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 264 + this.#insertBefore(parent, currentNode, child); 265 + return this.#morphNode([currentNode, reference]); 266 + } 267 + } 268 + } 269 + } 270 + currentNode = currentNode.nextSibling; 271 + } 272 + if (nextMatchByTagName) { 273 + this.#insertBefore(parent, nextMatchByTagName, child); 274 + this.#morphNode([nextMatchByTagName, reference]); 275 + } 276 + else { 277 + const newNode = reference.cloneNode(true); 278 + if (this.#beforeNodeAdded?.(newNode) ?? true) { 279 + this.#insertBefore(parent, newNode, child); 280 + this.#afterNodeAdded?.(newNode); 281 + } 282 + } 283 + this.#afterNodeMorphed?.(child, writableNode(reference)); 284 + } 285 + #updateProperty(node, propertyName, newValue) { 286 + const previousValue = node[propertyName]; 287 + if (previousValue !== newValue && (this.#beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 288 + node[propertyName] = newValue; 289 + this.#afterPropertyUpdated?.(node, propertyName, previousValue); 290 + } 291 + } 292 + #replaceNode(node, newNode) { 293 + if ((this.#beforeNodeRemoved?.(node) ?? true) && (this.#beforeNodeAdded?.(newNode) ?? true)) { 294 + node.replaceWith(newNode); 295 + this.#afterNodeAdded?.(newNode); 296 + this.#afterNodeRemoved?.(node); 297 + } 298 + } 299 + #insertBefore(parent, node, insertionPoint) { 300 + if (node === insertionPoint) 301 + return; 302 + parent.insertBefore(node, insertionPoint); 303 + } 304 + #appendChild(node, newNode) { 305 + if (this.#beforeNodeAdded?.(newNode) ?? true) { 306 + node.appendChild(newNode); 307 + this.#afterNodeAdded?.(newNode); 308 + } 309 + } 310 + #removeNode(node) { 311 + if (this.#beforeNodeRemoved?.(node) ?? true) { 312 + node.remove(); 313 + this.#afterNodeRemoved?.(node); 314 + } 315 + } 316 + } 317 + function writableNode(node) { 318 + return node; 319 + } 320 + function isMatchingElementPair(pair) { 321 + const [a, b] = pair; 322 + return isElement(a) && isElement(b) && a.localName === b.localName; 323 + } 324 + function isParentNodePair(pair) { 325 + return isParentNode(pair[0]) && isParentNode(pair[1]); 326 + } 327 + function isElement(node) { 328 + return node.nodeType === 1; 329 + } 330 + function isMedia(element) { 331 + return element.localName === "video" || element.localName === "audio"; 332 + } 333 + function isInput(element) { 334 + return element.localName === "input"; 335 + } 336 + function isOption(element) { 337 + return element.localName === "option"; 338 + } 339 + function isTextArea(element) { 340 + return element.localName === "textarea"; 341 + } 342 + function isHead(element) { 343 + return element.localName === "head"; 344 + } 345 + const parentNodeTypes = new Set([1, 9, 11]); 346 + function isParentNode(node) { 347 + return parentNodeTypes.has(node.nodeType); 348 + } 349 + //# sourceMappingURL=morphlex.js.map
-60
src/morphlex.ts
··· 1 1 type IdSet = Set<string> 2 2 type IdMap = WeakMap<ReadonlyNode<Node>, IdSet> 3 - type SensivityMap = WeakMap<ReadonlyNode<Node>, number> 4 3 5 4 // Maps to a type that can only read properties 6 5 type StrongReadonly<T> = { readonly [K in keyof T as T[K] extends Function ? never : K]: T[K] } ··· 76 75 77 76 class Morph { 78 77 readonly #idMap: IdMap 79 - readonly #sensivityMap: SensivityMap 80 78 81 79 readonly #ignoreActiveValue: boolean 82 80 readonly #preserveModifiedValues: boolean ··· 93 91 94 92 constructor(options: Options = {}) { 95 93 this.#idMap = new WeakMap() 96 - this.#sensivityMap = new WeakMap() 97 94 98 95 this.#ignoreActiveValue = options.ignoreActiveValue || false 99 96 this.#preserveModifiedValues = options.preserveModifiedValues || false ··· 139 136 #buildMaps([node, reference]: NodeReferencePair<ParentNode>): void { 140 137 this.#mapIdSets(node) 141 138 this.#mapIdSets(reference) 142 - this.#mapSensivity(node) 143 - } 144 - 145 - #mapSensivity(node: ReadonlyNode<ParentNode>): void { 146 - const sensitiveElements = node.querySelectorAll("audio,canvas,embed,iframe,input,object,textarea,video") 147 - 148 - const sensitiveElementsLength = sensitiveElements.length 149 - for (let i = 0; i < sensitiveElementsLength; i++) { 150 - const sensitiveElement = sensitiveElements[i] 151 - let sensivity = 0 152 - 153 - if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { 154 - sensivity++ 155 - 156 - if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity++ 157 - if (sensitiveElement === document.activeElement) sensivity++ 158 - } else { 159 - sensivity += 3 160 - 161 - if (isMedia(sensitiveElement) && !sensitiveElement.ended) { 162 - if (!sensitiveElement.paused) sensivity++ 163 - if (sensitiveElement.currentTime > 0) sensivity++ 164 - } 165 - } 166 - 167 - let current: ReadonlyNode<Element> | null = sensitiveElement 168 - while (current) { 169 - this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity) 170 - if (current === node) break 171 - current = current.parentElement 172 - } 173 - } 174 139 } 175 140 176 141 // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. ··· 413 378 #insertBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode): void { 414 379 if (node === insertionPoint) return 415 380 416 - if (isElement(node)) { 417 - const sensitivity = this.#sensivityMap.get(node) ?? 0 418 - 419 - if (sensitivity > 0) { 420 - let previousNode = node.previousSibling 421 - 422 - while (previousNode) { 423 - const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0 424 - 425 - if (previousNodeSensitivity < sensitivity) { 426 - parent.insertBefore(previousNode, node.nextSibling) 427 - 428 - if (previousNode === insertionPoint) return 429 - previousNode = node.previousSibling 430 - } else break 431 - } 432 - } 433 - } 434 - 435 381 parent.insertBefore(node, insertionPoint) 436 382 } 437 383 ··· 467 413 function isElement(node: ReadonlyNode<Node>): node is ReadonlyNode<Element> 468 414 function isElement(node: Node | ReadonlyNode<Node>): boolean { 469 415 return node.nodeType === 1 470 - } 471 - 472 - function isMedia(element: Element): element is HTMLMediaElement 473 - function isMedia(element: ReadonlyNode<Element>): element is ReadonlyNode<HTMLMediaElement> 474 - function isMedia(element: Element | ReadonlyNode<Element>): boolean { 475 - return element.localName === "video" || element.localName === "audio" 476 416 } 477 417 478 418 function isInput(element: Element): element is HTMLInputElement
-523
test/morphlex-coverage.test.ts
··· 87 87 88 88 expect(parent.textContent).toBe("Updated") 89 89 }) 90 - }) 91 - 92 - describe("Sensitivity mapping for media elements", () => { 93 - it("should handle media elements with various states", () => { 94 - const parent = document.createElement("div") 95 - const video = document.createElement("video") 96 - video.id = "video1" 97 - parent.appendChild(video) 98 - 99 - const reference = document.createElement("div") 100 - const refVideo = document.createElement("video") 101 - refVideo.id = "video1" 102 - refVideo.setAttribute("src", "test.mp4") 103 - reference.appendChild(refVideo) 104 - 105 - morph(parent, reference) 106 - 107 - expect(parent.querySelector("video")).toBeTruthy() 108 - }) 109 - 110 - it("should handle audio elements", () => { 111 - const parent = document.createElement("div") 112 - const audio = document.createElement("audio") 113 - audio.id = "audio1" 114 - parent.appendChild(audio) 115 - 116 - const reference = document.createElement("div") 117 - const refAudio = document.createElement("audio") 118 - refAudio.id = "audio1" 119 - refAudio.setAttribute("src", "test.mp3") 120 - reference.appendChild(refAudio) 121 - 122 - morph(parent, reference) 123 - 124 - expect(parent.querySelector("audio")).toBeTruthy() 125 - }) 126 - 127 - it("should handle canvas elements", () => { 128 - const parent = document.createElement("div") 129 - const canvas = document.createElement("canvas") 130 - canvas.id = "canvas1" 131 - parent.appendChild(canvas) 132 - 133 - const reference = document.createElement("div") 134 - const refCanvas = document.createElement("canvas") 135 - refCanvas.id = "canvas1" 136 - refCanvas.width = 800 137 - reference.appendChild(refCanvas) 138 - 139 - morph(parent, reference) 140 - 141 - expect(parent.querySelector("canvas")).toBeTruthy() 142 - }) 143 - 144 - it("should handle embed elements", () => { 145 - const parent = document.createElement("div") 146 - const embed = document.createElement("embed") 147 - embed.id = "embed1" 148 - parent.appendChild(embed) 149 - 150 - const reference = document.createElement("div") 151 - const refEmbed = document.createElement("embed") 152 - refEmbed.id = "embed1" 153 - refEmbed.setAttribute("src", "test.pdf") 154 - reference.appendChild(refEmbed) 155 - 156 - morph(parent, reference) 157 - 158 - expect(parent.querySelector("embed")).toBeTruthy() 159 - }) 160 - 161 - it("should handle iframe elements", () => { 162 - const parent = document.createElement("div") 163 - const iframe = document.createElement("iframe") 164 - iframe.id = "iframe1" 165 - parent.appendChild(iframe) 166 - 167 - const reference = document.createElement("div") 168 - const refIframe = document.createElement("iframe") 169 - refIframe.id = "iframe1" 170 - refIframe.setAttribute("src", "test.html") 171 - reference.appendChild(refIframe) 172 - 173 - morph(parent, reference) 174 - 175 - expect(parent.querySelector("iframe")).toBeTruthy() 176 - }) 177 - 178 - it("should handle object elements", () => { 179 - const parent = document.createElement("div") 180 - const object = document.createElement("object") 181 - object.id = "object1" 182 - parent.appendChild(object) 183 - 184 - const reference = document.createElement("div") 185 - const refObject = document.createElement("object") 186 - refObject.id = "object1" 187 - refObject.setAttribute("data", "test.pdf") 188 - reference.appendChild(refObject) 189 - 190 - morph(parent, reference) 191 - 192 - expect(parent.querySelector("object")).toBeTruthy() 193 - }) 194 - 195 - it("should handle input as active element", () => { 196 - const parent = document.createElement("div") 197 - container.appendChild(parent) 198 - 199 - const input = document.createElement("input") 200 - input.id = "input1" 201 - input.value = "test" 202 - parent.appendChild(input) 203 - 204 - // Focus the input to make it active 205 - input.focus() 206 - 207 - const reference = document.createElement("div") 208 - const refInput = document.createElement("input") 209 - refInput.id = "input1" 210 - refInput.value = "updated" 211 - reference.appendChild(refInput) 212 - 213 - morph(parent, reference, { ignoreActiveValue: true }) 214 - 215 - // Value should be preserved because input is active 216 - expect(input.value).toBe("test") 217 - }) 218 90 219 91 it("should handle textarea as active element", () => { 220 92 const parent = document.createElement("div") ··· 498 370 }) 499 371 }) 500 372 501 - describe("Sensitivity-based insertBefore", () => { 502 - it("should handle sensitivity reordering when previousNode is insertionPoint", () => { 503 - const parent = document.createElement("div") 504 - container.appendChild(parent) 505 - 506 - const input1 = document.createElement("input") 507 - input1.id = "input1" 508 - input1.value = "test" 509 - input1.defaultValue = "" 510 - 511 - const input2 = document.createElement("input") 512 - input2.id = "input2" 513 - input2.value = "test2" 514 - input2.defaultValue = "" 515 - 516 - parent.appendChild(input1) 517 - parent.appendChild(input2) 518 - 519 - const reference = document.createElement("div") 520 - const refInput2 = document.createElement("input") 521 - refInput2.id = "input2" 522 - refInput2.value = "test2" 523 - 524 - const refInput1 = document.createElement("input") 525 - refInput1.id = "input1" 526 - refInput1.value = "test" 527 - 528 - reference.appendChild(refInput2) 529 - reference.appendChild(refInput1) 530 - 531 - morph(parent, reference) 532 - 533 - expect(parent.children[0].id).toBe("input2") 534 - expect(parent.children[1].id).toBe("input1") 535 - }) 536 - 537 - it("should break sensitivity reordering loop when previousNodeSensitivity >= sensitivity", () => { 538 - const parent = document.createElement("div") 539 - container.appendChild(parent) 540 - 541 - // Create inputs with different sensitivity levels 542 - const input1 = document.createElement("input") 543 - input1.id = "input1" 544 - input1.value = "modified1" 545 - input1.defaultValue = "default1" 546 - 547 - const input2 = document.createElement("input") 548 - input2.id = "input2" 549 - input2.value = "modified2" 550 - input2.defaultValue = "default2" 551 - 552 - const input3 = document.createElement("input") 553 - input3.id = "input3" 554 - input3.value = "modified3" 555 - input3.defaultValue = "default3" 556 - 557 - parent.appendChild(input1) 558 - parent.appendChild(input2) 559 - parent.appendChild(input3) 560 - 561 - const reference = document.createElement("div") 562 - const refInput3 = document.createElement("input") 563 - refInput3.id = "input3" 564 - 565 - const refInput1 = document.createElement("input") 566 - refInput1.id = "input1" 567 - 568 - const refInput2 = document.createElement("input") 569 - refInput2.id = "input2" 570 - 571 - reference.appendChild(refInput3) 572 - reference.appendChild(refInput1) 573 - reference.appendChild(refInput2) 574 - 575 - morph(parent, reference) 576 - 577 - // Verify elements were reordered 578 - expect(parent.children.length).toBe(3) 579 - }) 580 - 581 - it("should handle insertBefore when node is not an element", () => { 582 - const parent = document.createElement("div") 583 - const text1 = document.createTextNode("First") 584 - const text2 = document.createTextNode("Second") 585 - parent.appendChild(text1) 586 - parent.appendChild(text2) 587 - 588 - const reference = document.createElement("div") 589 - const refText1 = document.createTextNode("First Updated") 590 - const refText2 = document.createTextNode("Second") 591 - reference.appendChild(refText1) 592 - reference.appendChild(refText2) 593 - 594 - morph(parent, reference) 595 - 596 - expect(parent.textContent).toBe("First UpdatedSecond") 597 - }) 598 - 599 - it("should handle insertBefore with zero sensitivity", () => { 600 - const parent = document.createElement("div") 601 - const div1 = document.createElement("div") 602 - div1.id = "div1" 603 - const div2 = document.createElement("div") 604 - div2.id = "div2" 605 - 606 - parent.appendChild(div1) 607 - parent.appendChild(div2) 608 - 609 - const reference = document.createElement("div") 610 - const refDiv2 = document.createElement("div") 611 - refDiv2.id = "div2" 612 - const refDiv1 = document.createElement("div") 613 - refDiv1.id = "div1" 614 - 615 - reference.appendChild(refDiv2) 616 - reference.appendChild(refDiv1) 617 - 618 - morph(parent, reference) 619 - 620 - expect(parent.children[0].id).toBe("div2") 621 - expect(parent.children[1].id).toBe("div1") 622 - }) 623 - }) 624 - 625 373 describe("Callback cancellation", () => { 626 374 it("should call beforeAttributeUpdated and cancel attribute removal when it returns false", () => { 627 375 const div = document.createElement("div") ··· 751 499 expect(textNode.nodeValue).toBe("Updated") 752 500 }) 753 501 754 - it("should handle media elements that are playing", () => { 755 - // Test lines 159-163 - media sensitivity with playing state 756 - const parent = document.createElement("div") 757 - container.appendChild(parent) 758 - 759 - const video = document.createElement("video") 760 - video.id = "video1" 761 - // Mock playing state 762 - Object.defineProperty(video, "ended", { value: false, writable: true }) 763 - Object.defineProperty(video, "paused", { value: false, writable: true }) 764 - Object.defineProperty(video, "currentTime", { value: 5.0, writable: true }) 765 - parent.appendChild(video) 766 - 767 - const reference = document.createElement("div") 768 - const refVideo = document.createElement("video") 769 - refVideo.id = "video1" 770 - reference.appendChild(refVideo) 771 - 772 - morph(parent, reference) 773 - 774 - expect(parent.querySelector("video")).toBeTruthy() 775 - }) 776 - 777 502 it("should match elements by overlapping ID sets", () => { 778 503 // Test lines 372-373 - matching by overlapping ID sets 779 504 const parent = document.createElement("div") ··· 852 577 expect(parent.children[0].tagName).toBe("ARTICLE") 853 578 }) 854 579 855 - it("should continue sensitivity loop when reordering multiple nodes", () => { 856 - // Test lines 426-429 - continuing the sensitivity reordering loop 857 - const parent = document.createElement("div") 858 - container.appendChild(parent) 859 - 860 - // Create a chain of inputs with modified values (high sensitivity) 861 - const input1 = document.createElement("input") 862 - input1.id = "input1" 863 - input1.value = "modified1" 864 - input1.defaultValue = "default1" 865 - 866 - const input2 = document.createElement("input") 867 - input2.id = "input2" 868 - input2.value = "modified2" 869 - input2.defaultValue = "default2" 870 - 871 - const input3 = document.createElement("input") 872 - input3.id = "input3" 873 - input3.value = "modified3" 874 - input3.defaultValue = "default3" 875 - 876 - const div = document.createElement("div") 877 - div.id = "div1" 878 - 879 - parent.appendChild(div) 880 - parent.appendChild(input1) 881 - parent.appendChild(input2) 882 - parent.appendChild(input3) 883 - 884 - // Reference wants inputs in different order 885 - const reference = document.createElement("div") 886 - 887 - const refInput3 = document.createElement("input") 888 - refInput3.id = "input3" 889 - 890 - const refInput2 = document.createElement("input") 891 - refInput2.id = "input2" 892 - 893 - const refInput1 = document.createElement("input") 894 - refInput1.id = "input1" 895 - 896 - const refDiv = document.createElement("div") 897 - refDiv.id = "div1" 898 - 899 - reference.appendChild(refInput3) 900 - reference.appendChild(refInput2) 901 - reference.appendChild(refInput1) 902 - reference.appendChild(refDiv) 903 - 904 - morph(parent, reference) 905 - 906 - // The inputs should be reordered 907 - expect(parent.children.length).toBe(4) 908 - }) 909 - 910 580 describe("DOMParser edge cases", () => { 911 581 it("should explore parser behavior to trigger line 74", () => { 912 582 // Line 74 checks if doc.childNodes.length === 1 ··· 990 660 expect(afterAddCalled).toBe(true) 991 661 }) 992 662 993 - it("should handle multiple previousNode reorderings in sensitivity loop", () => { 994 - // Test for lines 426-429 with more complex scenario 995 - const parent = document.createElement("div") 996 - container.appendChild(parent) 997 - 998 - const regularDiv = document.createElement("div") 999 - regularDiv.id = "regular" 1000 - 1001 - const sensitiveInput1 = document.createElement("input") 1002 - sensitiveInput1.id = "sensitive1" 1003 - sensitiveInput1.value = "changed" 1004 - sensitiveInput1.defaultValue = "default" 1005 - 1006 - const sensitiveInput2 = document.createElement("input") 1007 - sensitiveInput2.id = "sensitive2" 1008 - sensitiveInput2.value = "changed2" 1009 - sensitiveInput2.defaultValue = "default2" 1010 - 1011 - const sensitiveInput3 = document.createElement("input") 1012 - sensitiveInput3.id = "sensitive3" 1013 - sensitiveInput3.value = "changed3" 1014 - sensitiveInput3.defaultValue = "default3" 1015 - 1016 - parent.appendChild(regularDiv) 1017 - parent.appendChild(sensitiveInput1) 1018 - parent.appendChild(sensitiveInput2) 1019 - parent.appendChild(sensitiveInput3) 1020 - 1021 - const reference = document.createElement("div") 1022 - 1023 - const refInput3 = document.createElement("input") 1024 - refInput3.id = "sensitive3" 1025 - 1026 - const refInput2 = document.createElement("input") 1027 - refInput2.id = "sensitive2" 1028 - 1029 - const refInput1 = document.createElement("input") 1030 - refInput1.id = "sensitive1" 1031 - 1032 - const refDiv = document.createElement("div") 1033 - refDiv.id = "regular" 1034 - 1035 - reference.appendChild(refInput3) 1036 - reference.appendChild(refInput2) 1037 - reference.appendChild(refInput1) 1038 - reference.appendChild(refDiv) 1039 - 1040 - morph(parent, reference) 1041 - 1042 - expect(parent.children.length).toBe(4) 1043 - }) 1044 - 1045 663 it("should match by overlapping ID sets in sibling scan - lines 372-373", () => { 1046 664 // Lines 372-373: Match element by overlapping ID sets when ID doesn't match 1047 665 // This requires: currentNode has ID != reference.id, but has nested IDs that overlap ··· 1123 741 expect(parent.querySelector("p")?.textContent).toBe("Test") 1124 742 }) 1125 743 1126 - it("should continue sensitivity reordering loop - lines 426-429", () => { 1127 - // Lines 426-429: previousNode reordering continues until break condition 1128 - const parent = document.createElement("div") 1129 - container.appendChild(parent) 1130 - 1131 - // Create low sensitivity element 1132 - const lowSensDiv = document.createElement("div") 1133 - lowSensDiv.id = "low" 1134 - 1135 - // Create multiple high sensitivity elements that will trigger reordering 1136 - const highSens1 = document.createElement("input") 1137 - highSens1.id = "high1" 1138 - highSens1.value = "modified" 1139 - highSens1.defaultValue = "default" 1140 - 1141 - const highSens2 = document.createElement("input") 1142 - highSens2.id = "high2" 1143 - highSens2.value = "modified" 1144 - highSens2.defaultValue = "default" 1145 - 1146 - const highSens3 = document.createElement("input") 1147 - highSens3.id = "high3" 1148 - highSens3.value = "modified" 1149 - highSens3.defaultValue = "default" 1150 - 1151 - // Low sensitivity first, then high sensitivity elements 1152 - parent.appendChild(lowSensDiv) 1153 - parent.appendChild(highSens1) 1154 - parent.appendChild(highSens2) 1155 - parent.appendChild(highSens3) 1156 - 1157 - // Reference wants high sensitivity elements first 1158 - const reference = document.createElement("div") 1159 - const refHigh3 = document.createElement("input") 1160 - refHigh3.id = "high3" 1161 - const refHigh2 = document.createElement("input") 1162 - refHigh2.id = "high2" 1163 - const refHigh1 = document.createElement("input") 1164 - refHigh1.id = "high1" 1165 - const refLow = document.createElement("div") 1166 - refLow.id = "low" 1167 - 1168 - reference.appendChild(refHigh3) 1169 - reference.appendChild(refHigh2) 1170 - reference.appendChild(refHigh1) 1171 - reference.appendChild(refLow) 1172 - 1173 - morph(parent, reference) 1174 - 1175 - // Verify reordering happened 1176 - expect(parent.children.length).toBe(4) 1177 - // The loop should have moved multiple previousNodes 1178 - }) 1179 - 1180 744 it("should handle case where child exists but refChild doesn't in loop - lines 332-333", () => { 1181 745 // Lines 332-333 are actually unreachable in the for loop 1182 746 // because we iterate up to refChildNodes.length, so refChild will always exist ··· 1238 802 expect(afterCalled).toBe(true) 1239 803 }) 1240 804 1241 - it("should continue while loop in sensitivity reordering - lines 426-429", () => { 1242 - // Lines 426-429: while loop continues, previousNode gets reassigned 1243 - // Need multiple low-sensitivity nodes before a high-sensitivity node 1244 - const parent = document.createElement("div") 1245 - container.appendChild(parent) 1246 - 1247 - // Multiple regular divs (low sensitivity) 1248 - const div1 = document.createElement("div") 1249 - div1.id = "div1" 1250 - const div2 = document.createElement("div") 1251 - div2.id = "div2" 1252 - const div3 = document.createElement("div") 1253 - div3.id = "div3" 1254 - 1255 - // High sensitivity input at the end 1256 - const input = document.createElement("input") 1257 - input.id = "input1" 1258 - input.value = "modified" 1259 - input.defaultValue = "default" 1260 - 1261 - parent.appendChild(div1) 1262 - parent.appendChild(div2) 1263 - parent.appendChild(div3) 1264 - parent.appendChild(input) 1265 - 1266 - // Reference wants input first - this will trigger insertBefore with sensitivity reordering 1267 - const reference = document.createElement("div") 1268 - const refInput = document.createElement("input") 1269 - refInput.id = "input1" 1270 - const refDiv1 = document.createElement("div") 1271 - refDiv1.id = "div1" 1272 - const refDiv2 = document.createElement("div") 1273 - refDiv2.id = "div2" 1274 - const refDiv3 = document.createElement("div") 1275 - refDiv3.id = "div3" 1276 - 1277 - reference.appendChild(refInput) 1278 - reference.appendChild(refDiv1) 1279 - reference.appendChild(refDiv2) 1280 - reference.appendChild(refDiv3) 1281 - 1282 - morph(parent, reference) 1283 - 1284 - // Input should be moved to front, with divs following 1285 - expect(parent.children[0].id).toBe("input1") 1286 - }) 1287 - 1288 805 describe("Exact uncovered line tests", () => { 1289 806 it("should cancel morphing with beforeNodeMorphed returning false in morphChildElement - line 300", () => { 1290 807 // Line 300: return early when beforeNodeMorphed returns false in morphChildElement ··· 1351 868 1352 869 expect(beforeCalled).toBe(true) 1353 870 expect(afterCalled).toBe(true) 1354 - }) 1355 - 1356 - it("should reorder with sensitivity - moving multiple previous nodes - lines 426-429", () => { 1357 - // Lines 426-429: while loop continues moving previousNode 1358 - const parent = document.createElement("div") 1359 - container.appendChild(parent) 1360 - 1361 - // Create a scenario where multiple low-sensitivity nodes need to be moved past a high-sensitivity node 1362 - const div1 = document.createElement("div") 1363 - div1.id = "div1" 1364 - const div2 = document.createElement("div") 1365 - div2.id = "div2" 1366 - 1367 - // High sensitivity input 1368 - const input = document.createElement("input") 1369 - input.id = "input" 1370 - input.value = "modified" 1371 - input.defaultValue = "default" 1372 - 1373 - parent.appendChild(div1) 1374 - parent.appendChild(div2) 1375 - parent.appendChild(input) 1376 - 1377 - // Reference wants input first - will trigger sensitivity reordering 1378 - const reference = document.createElement("div") 1379 - const refInput = document.createElement("input") 1380 - refInput.id = "input" 1381 - const refDiv1 = document.createElement("div") 1382 - refDiv1.id = "div1" 1383 - const refDiv2 = document.createElement("div") 1384 - refDiv2.id = "div2" 1385 - 1386 - reference.appendChild(refInput) 1387 - reference.appendChild(refDiv1) 1388 - reference.appendChild(refDiv2) 1389 - 1390 - morph(parent, reference) 1391 - 1392 - // Input should be first due to higher sensitivity 1393 - expect(parent.children[0].id).toBe("input") 1394 871 }) 1395 872 }) 1396 873 })
+1 -92
test/morphlex-loops.test.ts
··· 15 15 } 16 16 }) 17 17 18 - describe("Sensitivity-based reordering", () => { 19 - it("should not infinite loop when reordering sensitive elements", () => { 20 - const parent = document.createElement("div") 21 - 22 - // Create input (sensitive element) 23 - const input = document.createElement("input") as HTMLInputElement 24 - input.id = "input1" 25 - input.value = "test" 26 - 27 - // Create regular div 28 - const div1 = document.createElement("div") 29 - div1.id = "div1" 30 - div1.textContent = "First" 31 - 32 - const div2 = document.createElement("div") 33 - div2.id = "div2" 34 - div2.textContent = "Second" 35 - 36 - parent.appendChild(div1) 37 - parent.appendChild(input) 38 - parent.appendChild(div2) 39 - 40 - // Reference wants to reorder them 41 - const reference = document.createElement("div") 42 - 43 - const refDiv1 = document.createElement("div") 44 - refDiv1.id = "div1" 45 - refDiv1.textContent = "First" 46 - 47 - const refDiv2 = document.createElement("div") 48 - refDiv2.id = "div2" 49 - refDiv2.textContent = "Second" 50 - 51 - const refInput = document.createElement("input") as HTMLInputElement 52 - refInput.id = "input1" 53 - 54 - reference.appendChild(refDiv1) 55 - reference.appendChild(refDiv2) 56 - reference.appendChild(refInput) 57 - 58 - // This should complete without infinite loop 59 - const startTime = Date.now() 60 - morph(parent, reference) 61 - const endTime = Date.now() 62 - 63 - // Should complete in reasonable time (< 1 second) 64 - expect(endTime - startTime).toBeLessThan(1000) 65 - expect(parent.children.length).toBe(3) 66 - }) 67 - 68 - it("should not infinite loop with multiple sensitive elements", () => { 69 - const parent = document.createElement("div") 70 - 71 - const input1 = document.createElement("input") as HTMLInputElement 72 - input1.id = "input1" 73 - 74 - const input2 = document.createElement("input") as HTMLInputElement 75 - input2.id = "input2" 76 - 77 - const textarea = document.createElement("textarea") 78 - textarea.id = "textarea1" 79 - 80 - parent.appendChild(input1) 81 - parent.appendChild(input2) 82 - parent.appendChild(textarea) 83 - 84 - // Reverse order 85 - const reference = document.createElement("div") 86 - 87 - const refTextarea = document.createElement("textarea") 88 - refTextarea.id = "textarea1" 89 - 90 - const refInput2 = document.createElement("input") as HTMLInputElement 91 - refInput2.id = "input2" 92 - 93 - const refInput1 = document.createElement("input") as HTMLInputElement 94 - refInput1.id = "input1" 95 - 96 - reference.appendChild(refTextarea) 97 - reference.appendChild(refInput2) 98 - reference.appendChild(refInput1) 99 - 100 - const startTime = Date.now() 101 - morph(parent, reference) 102 - const endTime = Date.now() 103 - 104 - expect(endTime - startTime).toBeLessThan(1000) 105 - expect(parent.children[0].id).toBe("textarea1") 106 - expect(parent.children[1].id).toBe("input2") 107 - expect(parent.children[2].id).toBe("input1") 108 - }) 109 - 18 + describe("Input value handling", () => { 110 19 it("should not infinite loop with modified input value", () => { 111 20 const parent = document.createElement("div") 112 21