Precise DOM morphing
morphing typescript dom

Better minification with # private properties

+62 -62
+62 -62
src/morphlex.ts
··· 101 } 102 103 class Morph { 104 - private readonly idMap: IdMap = new WeakMap() 105 - private readonly options: Options 106 107 constructor(options: Options = {}) { 108 - this.options = options 109 } 110 111 // Find longest increasing subsequence to minimize moves during reordering 112 // Returns the indices in the sequence that form the LIS 113 - private longestIncreasingSubsequence(sequence: Array<number>): Array<number> { 114 const n = sequence.length 115 if (n === 0) return [] 116 ··· 167 168 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { 169 if (isParentNode(from)) { 170 - this.mapIdSets(from) 171 } 172 173 if (to instanceof NodeList) { 174 - this.mapIdSetsForEach(to) 175 - this.morphOneToMany(from, to) 176 } else if (isParentNode(to)) { 177 - this.mapIdSets(to) 178 - this.morphOneToOne(from, to) 179 } 180 } 181 182 - private morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void { 183 const length = to.length 184 185 if (length === 0) { 186 - this.removeNode(from) 187 } else if (length === 1) { 188 - this.morphOneToOne(from, to[0]!) 189 } else if (length > 1) { 190 const newNodes = Array.from(to) 191 - this.morphOneToOne(from, newNodes.shift()!) 192 const insertionPoint = from.nextSibling 193 const parent = from.parentNode || document 194 195 for (const newNode of newNodes) { 196 - if (this.options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) { 197 moveBefore(parent, newNode, insertionPoint) 198 - this.options.afterNodeAdded?.(newNode) 199 } 200 } 201 } 202 } 203 204 - private morphOneToOne(from: ChildNode, to: ChildNode): void { 205 // Fast path: if nodes are exactly the same object, skip morphing 206 if (from === to) return 207 if (from.isEqualNode?.(to)) return 208 209 - if (!(this.options.beforeNodeVisited?.(from, to) ?? true)) return 210 211 const pair: PairOfNodes<ChildNode> = [from, to] 212 213 if (isElementPair(pair)) { 214 if (isMatchingElementPair(pair)) { 215 - this.morphMatchingElements(pair) 216 } else { 217 - this.morphNonMatchingElements(pair) 218 } 219 } else { 220 - this.morphOtherNode(pair) 221 } 222 223 - this.options.afterNodeVisited?.(from, to) 224 } 225 226 - private morphMatchingElements(pair: PairOfMatchingElements<Element>): void { 227 const [from, to] = pair 228 229 if (from.hasAttributes() || to.hasAttributes()) { 230 - this.visitAttributes(pair) 231 } 232 233 if (isTextAreaElement(from) && isTextAreaElement(to)) { 234 - this.visitTextArea(pair as PairOfMatchingElements<HTMLTextAreaElement>) 235 } else if (from.hasChildNodes() || to.hasChildNodes()) { 236 this.visitChildNodes(pair) 237 } 238 } 239 240 - private morphNonMatchingElements([from, to]: PairOfNodes<Element>): void { 241 - this.replaceNode(from, to) 242 } 243 244 - private morphOtherNode([from, to]: PairOfNodes<ChildNode>): void { 245 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 246 from.nodeValue = to.nodeValue 247 } else { 248 - this.replaceNode(from, to) 249 } 250 } 251 252 - private visitAttributes([from, to]: PairOfMatchingElements<Element>): void { 253 if (from.hasAttribute("morphlex-dirty")) { 254 from.removeAttribute("morphlex-dirty") 255 } ··· 258 for (const { name, value } of to.attributes) { 259 if (name === "value") { 260 if (isInputElement(from) && from.value !== value) { 261 - if (!this.options.preserveModified || from.value === from.defaultValue) { 262 from.value = value 263 } 264 } ··· 266 267 if (name === "selected") { 268 if (isOptionElement(from) && !from.selected) { 269 - if (!this.options.preserveModified || from.selected === from.defaultSelected) { 270 from.selected = true 271 } 272 } ··· 274 275 if (name === "checked") { 276 if (isInputElement(from) && !from.checked) { 277 - if (!this.options.preserveModified || from.checked === from.defaultChecked) { 278 from.checked = true 279 } 280 } ··· 282 283 const oldValue = from.getAttribute(name) 284 285 - if (oldValue !== value && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 286 from.setAttribute(name, value) 287 - this.options.afterAttributeUpdated?.(from, name, oldValue) 288 } 289 } 290 ··· 297 if (!to.hasAttribute(name)) { 298 if (name === "selected") { 299 if (isOptionElement(from) && from.selected) { 300 - if (!this.options.preserveModified || from.selected === from.defaultSelected) { 301 from.selected = false 302 } 303 } ··· 305 306 if (name === "checked") { 307 if (isInputElement(from) && from.checked) { 308 - if (!this.options.preserveModified || from.checked === from.defaultChecked) { 309 from.checked = false 310 } 311 } 312 } 313 314 - if (this.options.beforeAttributeUpdated?.(from, name, null) ?? true) { 315 from.removeAttribute(name) 316 - this.options.afterAttributeUpdated?.(from, name, value) 317 } 318 } 319 } 320 } 321 322 - private visitTextArea([from, to]: PairOfMatchingElements<HTMLTextAreaElement>): void { 323 const newTextContent = to.textContent || "" 324 const isModified = from.value !== from.defaultValue 325 ··· 328 from.textContent = newTextContent 329 } 330 331 - if (this.options.preserveModified && isModified) return 332 333 from.value = from.defaultValue 334 } 335 336 visitChildNodes([from, to]: PairOfMatchingElements<Element>): void { 337 - if (!(this.options.beforeChildrenVisited?.(from) ?? true)) return 338 const parent = from 339 340 const fromChildNodes = from.childNodes ··· 388 const element = toChildNodes[i]! 389 if (!isElement(element)) continue 390 391 - const idSet = this.idMap.get(element) 392 if (!idSet) continue 393 394 candidateLoop: for (const candidate of candidateElements) { 395 if (isElement(candidate)) { 396 - const candidateIdSet = this.idMap.get(candidate) 397 if (candidateIdSet) { 398 for (const id of idSet) { 399 if (candidateIdSet.has(id)) { ··· 500 } 501 502 // Find LIS - these nodes don't need to move 503 - const lisIndices = this.longestIncreasingSubsequence(sequence) 504 const shouldNotMove = new Set<number>() 505 for (const idx of lisIndices) { 506 shouldNotMove.add(sequence[idx]!) ··· 517 if (!shouldNotMove.has(matchIndex)) { 518 moveBefore(parent, match, insertionPoint) 519 } 520 - this.morphOneToOne(match, node) 521 insertionPoint = match.nextSibling 522 // Skip over any nodes that will be removed to avoid unnecessary moves 523 while ( ··· 527 insertionPoint = insertionPoint.nextSibling 528 } 529 } else { 530 - if (this.options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) { 531 moveBefore(parent, node, insertionPoint) 532 - this.options.afterNodeAdded?.(node) 533 insertionPoint = node.nextSibling 534 // Skip over any nodes that will be removed to avoid unnecessary moves 535 while ( ··· 544 545 // Remove any remaining unmatched candidates 546 for (const candidate of candidateNodes) { 547 - this.removeNode(candidate) 548 } 549 550 for (const candidate of candidateElements) { 551 - this.removeNode(candidate) 552 } 553 554 - this.options.afterChildrenVisited?.(from) 555 } 556 557 - private replaceNode(node: ChildNode, newNode: ChildNode): void { 558 const parent = node.parentNode || document 559 const insertionPoint = node 560 - if (this.options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) { 561 moveBefore(parent, newNode, insertionPoint) 562 - this.options.afterNodeAdded?.(newNode) 563 - this.removeNode(node) 564 } 565 } 566 567 - private removeNode(node: ChildNode): void { 568 - if (this.options.beforeNodeRemoved?.(node) ?? true) { 569 node.remove() 570 - this.options.afterNodeRemoved?.(node) 571 } 572 } 573 574 - private mapIdSetsForEach(nodeList: NodeList): void { 575 for (const childNode of nodeList) { 576 if (isParentNode(childNode)) { 577 - this.mapIdSets(childNode) 578 } 579 } 580 } 581 582 // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 583 - private mapIdSets(node: ParentNode): void { 584 for (const elementWithId of node.querySelectorAll("[id]")) { 585 const id = elementWithId.id 586 ··· 589 let currentElement: Element | null = elementWithId 590 591 while (currentElement) { 592 - const idSet: IdSet | undefined = this.idMap.get(currentElement) 593 if (idSet) idSet.add(id) 594 - else this.idMap.set(currentElement, new Set([id])) 595 if (currentElement === node) break 596 currentElement = currentElement.parentElement 597 }
··· 101 } 102 103 class Morph { 104 + readonly #idMap: IdMap = new WeakMap() 105 + readonly #options: Options 106 107 constructor(options: Options = {}) { 108 + this.#options = options 109 } 110 111 // Find longest increasing subsequence to minimize moves during reordering 112 // Returns the indices in the sequence that form the LIS 113 + #longestIncreasingSubsequence(sequence: Array<number>): Array<number> { 114 const n = sequence.length 115 if (n === 0) return [] 116 ··· 167 168 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { 169 if (isParentNode(from)) { 170 + this.#mapIdSets(from) 171 } 172 173 if (to instanceof NodeList) { 174 + this.#mapIdSetsForEach(to) 175 + this.#morphOneToMany(from, to) 176 } else if (isParentNode(to)) { 177 + this.#mapIdSets(to) 178 + this.#morphOneToOne(from, to) 179 } 180 } 181 182 + #morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void { 183 const length = to.length 184 185 if (length === 0) { 186 + this.#removeNode(from) 187 } else if (length === 1) { 188 + this.#morphOneToOne(from, to[0]!) 189 } else if (length > 1) { 190 const newNodes = Array.from(to) 191 + this.#morphOneToOne(from, newNodes.shift()!) 192 const insertionPoint = from.nextSibling 193 const parent = from.parentNode || document 194 195 for (const newNode of newNodes) { 196 + if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) { 197 moveBefore(parent, newNode, insertionPoint) 198 + this.#options.afterNodeAdded?.(newNode) 199 } 200 } 201 } 202 } 203 204 + #morphOneToOne(from: ChildNode, to: ChildNode): void { 205 // Fast path: if nodes are exactly the same object, skip morphing 206 if (from === to) return 207 if (from.isEqualNode?.(to)) return 208 209 + if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 210 211 const pair: PairOfNodes<ChildNode> = [from, to] 212 213 if (isElementPair(pair)) { 214 if (isMatchingElementPair(pair)) { 215 + this.#morphMatchingElements(pair) 216 } else { 217 + this.#morphNonMatchingElements(pair) 218 } 219 } else { 220 + this.#morphOtherNode(pair) 221 } 222 223 + this.#options.afterNodeVisited?.(from, to) 224 } 225 226 + #morphMatchingElements(pair: PairOfMatchingElements<Element>): void { 227 const [from, to] = pair 228 229 if (from.hasAttributes() || to.hasAttributes()) { 230 + this.#visitAttributes(pair) 231 } 232 233 if (isTextAreaElement(from) && isTextAreaElement(to)) { 234 + this.#visitTextArea(pair as PairOfMatchingElements<HTMLTextAreaElement>) 235 } else if (from.hasChildNodes() || to.hasChildNodes()) { 236 this.visitChildNodes(pair) 237 } 238 } 239 240 + #morphNonMatchingElements([from, to]: PairOfNodes<Element>): void { 241 + this.#replaceNode(from, to) 242 } 243 244 + #morphOtherNode([from, to]: PairOfNodes<ChildNode>): void { 245 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 246 from.nodeValue = to.nodeValue 247 } else { 248 + this.#replaceNode(from, to) 249 } 250 } 251 252 + #visitAttributes([from, to]: PairOfMatchingElements<Element>): void { 253 if (from.hasAttribute("morphlex-dirty")) { 254 from.removeAttribute("morphlex-dirty") 255 } ··· 258 for (const { name, value } of to.attributes) { 259 if (name === "value") { 260 if (isInputElement(from) && from.value !== value) { 261 + if (!this.#options.preserveModified || from.value === from.defaultValue) { 262 from.value = value 263 } 264 } ··· 266 267 if (name === "selected") { 268 if (isOptionElement(from) && !from.selected) { 269 + if (!this.#options.preserveModified || from.selected === from.defaultSelected) { 270 from.selected = true 271 } 272 } ··· 274 275 if (name === "checked") { 276 if (isInputElement(from) && !from.checked) { 277 + if (!this.#options.preserveModified || from.checked === from.defaultChecked) { 278 from.checked = true 279 } 280 } ··· 282 283 const oldValue = from.getAttribute(name) 284 285 + if (oldValue !== value && (this.#options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 286 from.setAttribute(name, value) 287 + this.#options.afterAttributeUpdated?.(from, name, oldValue) 288 } 289 } 290 ··· 297 if (!to.hasAttribute(name)) { 298 if (name === "selected") { 299 if (isOptionElement(from) && from.selected) { 300 + if (!this.#options.preserveModified || from.selected === from.defaultSelected) { 301 from.selected = false 302 } 303 } ··· 305 306 if (name === "checked") { 307 if (isInputElement(from) && from.checked) { 308 + if (!this.#options.preserveModified || from.checked === from.defaultChecked) { 309 from.checked = false 310 } 311 } 312 } 313 314 + if (this.#options.beforeAttributeUpdated?.(from, name, null) ?? true) { 315 from.removeAttribute(name) 316 + this.#options.afterAttributeUpdated?.(from, name, value) 317 } 318 } 319 } 320 } 321 322 + #visitTextArea([from, to]: PairOfMatchingElements<HTMLTextAreaElement>): void { 323 const newTextContent = to.textContent || "" 324 const isModified = from.value !== from.defaultValue 325 ··· 328 from.textContent = newTextContent 329 } 330 331 + if (this.#options.preserveModified && isModified) return 332 333 from.value = from.defaultValue 334 } 335 336 visitChildNodes([from, to]: PairOfMatchingElements<Element>): void { 337 + if (!(this.#options.beforeChildrenVisited?.(from) ?? true)) return 338 const parent = from 339 340 const fromChildNodes = from.childNodes ··· 388 const element = toChildNodes[i]! 389 if (!isElement(element)) continue 390 391 + const idSet = this.#idMap.get(element) 392 if (!idSet) continue 393 394 candidateLoop: for (const candidate of candidateElements) { 395 if (isElement(candidate)) { 396 + const candidateIdSet = this.#idMap.get(candidate) 397 if (candidateIdSet) { 398 for (const id of idSet) { 399 if (candidateIdSet.has(id)) { ··· 500 } 501 502 // Find LIS - these nodes don't need to move 503 + const lisIndices = this.#longestIncreasingSubsequence(sequence) 504 const shouldNotMove = new Set<number>() 505 for (const idx of lisIndices) { 506 shouldNotMove.add(sequence[idx]!) ··· 517 if (!shouldNotMove.has(matchIndex)) { 518 moveBefore(parent, match, insertionPoint) 519 } 520 + this.#morphOneToOne(match, node) 521 insertionPoint = match.nextSibling 522 // Skip over any nodes that will be removed to avoid unnecessary moves 523 while ( ··· 527 insertionPoint = insertionPoint.nextSibling 528 } 529 } else { 530 + if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) { 531 moveBefore(parent, node, insertionPoint) 532 + this.#options.afterNodeAdded?.(node) 533 insertionPoint = node.nextSibling 534 // Skip over any nodes that will be removed to avoid unnecessary moves 535 while ( ··· 544 545 // Remove any remaining unmatched candidates 546 for (const candidate of candidateNodes) { 547 + this.#removeNode(candidate) 548 } 549 550 for (const candidate of candidateElements) { 551 + this.#removeNode(candidate) 552 } 553 554 + this.#options.afterChildrenVisited?.(from) 555 } 556 557 + #replaceNode(node: ChildNode, newNode: ChildNode): void { 558 const parent = node.parentNode || document 559 const insertionPoint = node 560 + if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) { 561 moveBefore(parent, newNode, insertionPoint) 562 + this.#options.afterNodeAdded?.(newNode) 563 + this.#removeNode(node) 564 } 565 } 566 567 + #removeNode(node: ChildNode): void { 568 + if (this.#options.beforeNodeRemoved?.(node) ?? true) { 569 node.remove() 570 + this.#options.afterNodeRemoved?.(node) 571 } 572 } 573 574 + #mapIdSetsForEach(nodeList: NodeList): void { 575 for (const childNode of nodeList) { 576 if (isParentNode(childNode)) { 577 + this.#mapIdSets(childNode) 578 } 579 } 580 } 581 582 // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 583 + #mapIdSets(node: ParentNode): void { 584 for (const elementWithId of node.querySelectorAll("[id]")) { 585 const id = elementWithId.id 586 ··· 589 let currentElement: Element | null = elementWithId 590 591 while (currentElement) { 592 + const idSet: IdSet | undefined = this.#idMap.get(currentElement) 593 if (idSet) idSet.add(id) 594 + else this.#idMap.set(currentElement, new Set([id])) 595 if (currentElement === node) break 596 currentElement = currentElement.parentElement 597 }