Precise DOM morphing
morphing typescript dom

Use a combination of idSets and idArrays and only flag dirty inputs when necessary (#37)

* Only flag dirty inputs if preserveChanges is disabled

* Use idSets for one side

* Simplify attribute loops

* Bump deps

authored by joel.drapper.me and committed by

GitHub 16fb1259 f7e26860

+65 -44
bun.lockb

This is a binary file and will not be displayed.

+8 -8
package.json
··· 27 27 "serve": "bun --bun vite --open /benchmark/" 28 28 }, 29 29 "devDependencies": { 30 - "@types/bun": "^1.3.1", 31 - "@typescript/native-preview": "^7.0.0-dev.20251104.1", 32 - "@vitest/browser": "^4.0.6", 33 - "@vitest/browser-playwright": "^4.0.6", 34 - "@vitest/coverage-v8": "^4.0.6", 35 - "@vitest/ui": "^4.0.6", 30 + "@types/bun": "^1.3.2", 31 + "@typescript/native-preview": "^7.0.0-dev.20251109.1", 32 + "@vitest/browser": "^4.0.8", 33 + "@vitest/browser-playwright": "^4.0.8", 34 + "@vitest/coverage-v8": "^4.0.8", 35 + "@vitest/ui": "^4.0.8", 36 36 "happy-dom": "^20.0.10", 37 - "oxlint": "^1.25.0", 37 + "oxlint": "^1.26.0", 38 38 "oxlint-tsgolint": "^0.4.0", 39 39 "prettier": "^3.6.2", 40 40 "typescript": "^5.9.3", 41 - "vitest": "^4.0.6" 41 + "vitest": "^4.0.8" 42 42 } 43 43 }
+57 -36
src/morphlex.ts
··· 9 9 const unmatchedElements: Set<number> = new Set() 10 10 const whitespaceNodes: Set<number> = new Set() 11 11 12 - type IdMap = WeakMap<Node, Array<string>> 12 + type IdSetMap = WeakMap<Node, Set<string>> 13 + type IdArrayMap = WeakMap<Node, Array<string>> 13 14 14 15 /** 15 16 * Configuration options for morphing operations. ··· 129 130 export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void { 130 131 if (typeof to === "string") to = parseFragment(to).childNodes 131 132 132 - if (isParentNode(from)) flagDirtyInputs(from) 133 + if (!options.preserveChanges && isParentNode(from)) flagDirtyInputs(from) 133 134 134 135 new Morph(options).morph(from, to) 135 136 } ··· 214 215 } 215 216 216 217 class Morph { 217 - readonly #idMap: IdMap = new WeakMap() 218 + readonly #idArrayMap: IdArrayMap = new WeakMap() 219 + readonly #idSetMap: IdSetMap = new WeakMap() 218 220 readonly #options: Options 219 221 220 222 constructor(options: Options = {}) { ··· 284 286 } 285 287 286 288 if (to instanceof NodeList) { 287 - this.#mapIdSetsForEach(to) 289 + this.#mapIdArraysForEach(to) 288 290 this.#morphOneToMany(from, to) 289 291 } else if (isParentNode(to)) { 290 - this.#mapIdSets(to) 292 + this.#mapIdArrays(to) 291 293 this.#morphOneToOne(from, to) 292 294 } 293 295 } ··· 365 367 } 366 368 367 369 // First pass: update/add attributes from reference (iterate forwards) 368 - const toAttributes = to.attributes 369 - for (let i = 0; i < toAttributes.length; i++) { 370 - const { name, value } = toAttributes[i]! 370 + for (const { name, value } of to.attributes) { 371 371 if (name === "value") { 372 372 if (isInputElement(from) && from.value !== value) { 373 373 if (!this.#options.preserveChanges || from.value === from.defaultValue) { ··· 400 400 } 401 401 } 402 402 403 - const fromAttrs = from.attributes 404 - 405 - // Second pass: remove excess attributes (iterate backwards for efficiency) 406 - for (let i = fromAttrs.length - 1; i >= 0; i--) { 407 - const { name, value } = fromAttrs[i]! 408 - 403 + // Second pass: remove excess attributes 404 + for (const { name, value } of from.attributes) { 409 405 if (!to.hasAttribute(name)) { 410 406 if (name === "selected") { 411 407 if (isOptionElement(from) && from.selected) { ··· 509 505 const element = toChildNodes[unmatchedIndex] as Element 510 506 511 507 const id = element.id 512 - const idSet = this.#idMap.get(element) 508 + const idArray = this.#idArrayMap.get(element) 513 509 514 - if (id === "" && !idSet) continue 510 + if (id === "" && !idArray) continue 515 511 516 512 candidateLoop: for (const candidateIndex of candidateElements) { 517 513 const candidate = fromChildNodes[candidateIndex] as Element ··· 525 521 break candidateLoop 526 522 } 527 523 528 - // Match by idSet 529 - if (idSet) { 530 - const candidateIdSet = this.#idMap.get(candidate) 524 + // Match by idArray (to) against idSet (from) 525 + if (idArray) { 526 + const candidateIdSet = this.#idSetMap.get(candidate) 531 527 if (candidateIdSet) { 532 - for (let i = 0; i < idSet.length; i++) { 533 - const setId = idSet[i]! 534 - for (let k = 0; k < candidateIdSet.length; k++) { 535 - if (candidateIdSet[k] === setId) { 536 - matches[unmatchedIndex] = candidateIndex 537 - seq[candidateIndex] = unmatchedIndex 538 - candidateElements.delete(candidateIndex) 539 - unmatchedElements.delete(unmatchedIndex) 540 - break candidateLoop 541 - } 528 + for (let i = 0; i < idArray.length; i++) { 529 + const arrayId = idArray[i]! 530 + if (candidateIdSet.has(arrayId)) { 531 + matches[unmatchedIndex] = candidateIndex 532 + seq[candidateIndex] = unmatchedIndex 533 + candidateElements.delete(candidateIndex) 534 + unmatchedElements.delete(unmatchedIndex) 535 + break candidateLoop 542 536 } 543 537 } 544 538 } ··· 687 681 } 688 682 } 689 683 690 - #mapIdSetsForEach(nodeList: NodeList): void { 684 + #mapIdArraysForEach(nodeList: NodeList): void { 691 685 for (const childNode of nodeList) { 692 686 if (isParentNode(childNode)) { 693 - this.#mapIdSets(childNode) 687 + this.#mapIdArrays(childNode) 688 + } 689 + } 690 + } 691 + 692 + // For each node with an ID, push that ID into the IdArray on the IdArrayMap, for each of its parent elements. 693 + #mapIdArrays(node: ParentNode): void { 694 + const idArrayMap = this.#idArrayMap 695 + 696 + for (const element of node.querySelectorAll("[id]")) { 697 + const id = element.id 698 + 699 + if (id === "") continue 700 + 701 + let currentElement: Element | null = element 702 + 703 + while (currentElement) { 704 + const idArray = idArrayMap.get(currentElement) 705 + if (idArray) { 706 + idArray.push(id) 707 + } else { 708 + idArrayMap.set(currentElement, [id]) 709 + } 710 + if (currentElement === node) break 711 + currentElement = currentElement.parentElement 694 712 } 695 713 } 696 714 } 697 715 698 - // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 716 + // For each node with an ID, add that ID into the IdSet on the IdSetMap, for each of its parent elements. 699 717 #mapIdSets(node: ParentNode): void { 700 - const idMap = this.#idMap 718 + const idSetMap = this.#idSetMap 701 719 702 720 for (const element of node.querySelectorAll("[id]")) { 703 721 const id = element.id ··· 707 725 let currentElement: Element | null = element 708 726 709 727 while (currentElement) { 710 - const idSet: Array<string> | undefined = idMap.get(currentElement) 711 - if (idSet) idSet.push(id) 712 - else idMap.set(currentElement, [id]) 728 + const idSet = idSetMap.get(currentElement) 729 + if (idSet) { 730 + idSet.add(id) 731 + } else { 732 + idSetMap.set(currentElement, new Set([id])) 733 + } 713 734 if (currentElement === node) break 714 735 currentElement = currentElement.parentElement 715 736 }