Precise DOM morphing
morphing typescript dom

Refactor

+89 -160
+89 -160
src/morphlex.ts
··· 1 - // Type declaration for moveBefore API (not yet in TypeScript DOM types) 2 - declare global { 3 - interface Element { 4 - moveBefore?(node: Node, child: Node | null): Node 5 - } 6 - } 7 - 8 1 type IdSet = Set<string> 9 - type IdMap = WeakMap<ReadonlyNode<Node>, IdSet> 10 - 11 - // Maps to a type that can only read properties 12 - type StrongReadonly<T> = { readonly [K in keyof T as T[K] extends Function ? never : K]: T[K] } 2 + type IdMap = WeakMap<Node, IdSet> 13 3 14 4 declare const brand: unique symbol 15 5 type Branded<T, B extends string> = T & { [brand]: B } 16 6 17 - type NodeReferencePair<N extends Node> = Readonly<[N, ReadonlyNode<N>]> 18 - type MatchingElementReferencePair<E extends Element> = Branded<NodeReferencePair<E>, "MatchingElementPair"> 19 - 20 - // Maps a Node to a type limited to read-only properties and methods for that Node 21 - type ReadonlyNode<N extends Node> = 22 - | N 23 - | (StrongReadonly<N> & { 24 - readonly cloneNode: (deep: true) => Node 25 - readonly childNodes: ReadonlyNodeList<ChildNode> 26 - readonly querySelectorAll: (query: string) => ReadonlyNodeList<Element> 27 - readonly parentElement: ReadonlyNode<Element> | null 28 - readonly hasAttribute: (name: string) => boolean 29 - readonly hasAttributes: () => boolean 30 - readonly hasChildNodes: () => boolean 31 - readonly children: ReadonlyNodeList<Element> 32 - }) 33 - 34 - // Maps a node to a read-only node list of nodes of that type 35 - type ReadonlyNodeList<N extends Node> = 36 - | NodeListOf<N> 37 - | { 38 - [Symbol.iterator](): IterableIterator<ReadonlyNode<N>> 39 - readonly [index: number]: ReadonlyNode<N> 40 - readonly length: NodeListOf<N>["length"] 41 - } 7 + type PairOfNodes<N extends Node> = [N, N] 8 + type PairOfMatchingElements<E extends Element> = Branded<PairOfNodes<E>, "MatchingElementPair"> 42 9 43 10 interface Options { 44 11 ignoreActiveValue?: boolean ··· 73 40 } 74 41 75 42 function parseChildNodeFromString(string: string): ChildNode { 76 - const parser = new DOMParser() 77 - const doc = parser.parseFromString(string, "text/html") 43 + const template = document.createElement("template") 44 + template.innerHTML = string 78 45 79 - const firstChild = doc.body.firstChild 46 + const firstChild = template.content.firstChild 80 47 if (firstChild) return firstChild 81 48 else throw new Error("[Morphlex] The string was not a valid HTML node.") 82 49 } 83 50 84 51 // Feature detection for moveBefore support (cached for performance) 85 - const supportsMoveBefore = typeof Element !== "undefined" && typeof Element.prototype.moveBefore === "function" 86 52 87 53 class Morph { 88 - readonly #idMap: IdMap 89 - 90 - readonly #ignoreActiveValue: boolean 91 - readonly #preserveModifiedValues: boolean 92 - readonly #beforeNodeMorphed?: (node: Node, referenceNode: Node) => boolean 93 - readonly #afterNodeMorphed?: (node: Node, referenceNode: Node) => void 94 - readonly #beforeNodeAdded?: (node: Node) => boolean 95 - readonly #afterNodeAdded?: (node: Node) => void 96 - readonly #beforeNodeRemoved?: (node: Node) => boolean 97 - readonly #afterNodeRemoved?: (node: Node) => void 98 - readonly #beforeAttributeUpdated?: (element: Element, attributeName: string, newValue: string | null) => boolean 99 - readonly #afterAttributeUpdated?: (element: Element, attributeName: string, previousValue: string | null) => void 100 - readonly #beforePropertyUpdated?: (node: Node, propertyName: PropertyKey, newValue: unknown) => boolean 101 - readonly #afterPropertyUpdated?: (node: Node, propertyName: PropertyKey, previousValue: unknown) => void 54 + readonly idMap: IdMap 55 + readonly options: Options 102 56 103 57 constructor(options: Options = {}) { 104 - this.#idMap = new WeakMap() 105 - 106 - this.#ignoreActiveValue = options.ignoreActiveValue || false 107 - this.#preserveModifiedValues = options.preserveModifiedValues || false 108 - this.#beforeNodeMorphed = options.beforeNodeMorphed 109 - this.#afterNodeMorphed = options.afterNodeMorphed 110 - this.#beforeNodeAdded = options.beforeNodeAdded 111 - this.#afterNodeAdded = options.afterNodeAdded 112 - this.#beforeNodeRemoved = options.beforeNodeRemoved 113 - this.#afterNodeRemoved = options.afterNodeRemoved 114 - this.#beforeAttributeUpdated = options.beforeAttributeUpdated 115 - this.#afterAttributeUpdated = options.afterAttributeUpdated 116 - this.#beforePropertyUpdated = options.beforePropertyUpdated 117 - this.#afterPropertyUpdated = options.afterPropertyUpdated 58 + this.idMap = new WeakMap() 59 + this.options = options 118 60 } 119 61 120 - morph(pair: NodeReferencePair<ChildNode>): void { 62 + morph(pair: PairOfNodes<ChildNode>): void { 121 63 this.#withAriaBusy(pair[0], () => { 122 64 if (isParentNodePair(pair)) this.#buildMaps(pair) 123 65 this.#morphNode(pair) 124 66 }) 125 67 } 126 68 127 - morphInner(pair: NodeReferencePair<Element>): void { 69 + morphInner(pair: PairOfNodes<Element>): void { 128 70 this.#withAriaBusy(pair[0], () => { 129 71 if (isMatchingElementPair(pair)) { 130 72 this.#buildMaps(pair) ··· 144 86 } else block() 145 87 } 146 88 147 - #buildMaps([node, reference]: NodeReferencePair<ParentNode>): void { 89 + #buildMaps([node, reference]: PairOfNodes<ParentNode>): void { 148 90 this.#mapIdSets(node) 149 91 this.#mapIdSets(reference) 150 92 } 151 93 152 94 // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 153 - #mapIdSets(node: ReadonlyNode<ParentNode>): void { 95 + #mapIdSets(node: ParentNode): void { 154 96 const elementsWithIds = node.querySelectorAll("[id]") 155 97 156 98 const elementsWithIdsLength = elementsWithIds.length ··· 161 103 // Ignore empty IDs 162 104 if (id === "") continue 163 105 164 - let current: ReadonlyNode<Element> | null = elementWithId 106 + let current: Element | null = elementWithId 165 107 166 108 while (current) { 167 - const idSet: IdSet | undefined = this.#idMap.get(current) 109 + const idSet: IdSet | undefined = this.idMap.get(current) 168 110 if (idSet) idSet.add(id) 169 - else this.#idMap.set(current, new Set([id])) 111 + else this.idMap.set(current, new Set([id])) 170 112 if (current === node) break 171 113 current = current.parentElement 172 114 } ··· 174 116 } 175 117 176 118 // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 177 - #morphNode(pair: NodeReferencePair<ChildNode>): void { 119 + #morphNode(pair: PairOfNodes<ChildNode>): void { 178 120 const [node, reference] = pair 179 121 180 - if (node.nodeType === 3 && reference.nodeType === 3) { 122 + if (isTextNode(node) && isTextNode(reference)) { 181 123 if (node.textContent === reference.textContent) return 182 124 } 183 125 ··· 185 127 else this.#morphOtherNode(pair) 186 128 } 187 129 188 - #morphMatchingElementNode(pair: MatchingElementReferencePair<Element>): void { 130 + #morphMatchingElementNode(pair: PairOfMatchingElements<Element>): void { 189 131 const [node, reference] = pair 190 132 191 - if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) return 133 + if (!(this.options.beforeNodeMorphed?.(node, reference) ?? true)) return 192 134 193 135 if (node.hasAttributes() || reference.hasAttributes()) this.#morphAttributes(pair) 194 136 195 137 // TODO: Should use a branded pair here. 196 138 this.#morphMatchingElementContent(pair) 197 139 198 - this.#afterNodeMorphed?.(node, writableNode(reference)) 140 + this.options.afterNodeMorphed?.(node, reference) 199 141 } 200 142 201 - #morphOtherNode([node, reference]: NodeReferencePair<ChildNode>): void { 202 - if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) return 143 + #morphOtherNode([node, reference]: PairOfNodes<ChildNode>): void { 144 + if (!(this.options.beforeNodeMorphed?.(node, reference) ?? true)) return 203 145 204 146 if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) { 205 147 // Handle text nodes, comments, and CDATA sections. 206 148 this.#updateProperty(node, "nodeValue", reference.nodeValue) 207 - } else this.#replaceNode(node, reference.cloneNode(true)) 149 + } else this.replaceNode(node, reference.cloneNode(true)) 208 150 209 - this.#afterNodeMorphed?.(node, writableNode(reference)) 151 + this.options.afterNodeMorphed?.(node, reference) 210 152 } 211 153 212 - #morphMatchingElementContent(pair: MatchingElementReferencePair<Element>): void { 154 + #morphMatchingElementContent(pair: PairOfMatchingElements<Element>): void { 213 155 const [node, reference] = pair 214 156 215 - if (isHead(node)) { 157 + if (isHeadElement(node)) { 216 158 // We can pass the reference as a head here becuase we know it's the same as the node. 217 - this.#morphHeadContents(pair as MatchingElementReferencePair<HTMLHeadElement>) 159 + this.#morphHeadContents(pair as PairOfMatchingElements<HTMLHeadElement>) 218 160 } else if (node.hasChildNodes() || reference.hasChildNodes()) this.#morphChildNodes(pair) 219 161 } 220 162 221 - #morphHeadContents([node, reference]: MatchingElementReferencePair<HTMLHeadElement>): void { 222 - const refChildNodesMap: Map<string, ReadonlyNode<Element>> = new Map() 163 + #morphHeadContents([node, reference]: PairOfMatchingElements<HTMLHeadElement>): void { 164 + const refChildNodesMap: Map<string, Element> = new Map() 223 165 224 166 // Generate a map of the reference head element’s child nodes, keyed by their outerHTML. 225 167 const referenceChildrenLength = reference.children.length ··· 237 179 // If the child is in the reference map already, we don't need to add it later. 238 180 // If it's not in the map, we need to remove it from the node. 239 181 if (refChild) refChildNodesMap.delete(key) 240 - else this.#removeNode(child) 182 + else this.removeNode(child) 241 183 } 242 184 243 185 // Any remaining nodes in the map should be appended to the head. 244 - for (const refChild of refChildNodesMap.values()) this.#appendChild(node, refChild.cloneNode(true)) 186 + for (const refChild of refChildNodesMap.values()) this.appendChild(node, refChild.cloneNode(true)) 245 187 } 246 188 247 - #morphAttributes([element, reference]: MatchingElementReferencePair<Element>): void { 189 + #morphAttributes([element, reference]: PairOfMatchingElements<Element>): void { 248 190 // Remove any excess attributes from the element that aren’t present in the reference. 249 191 for (const { name, value } of element.attributes) { 250 - if (!reference.hasAttribute(name) && (this.#beforeAttributeUpdated?.(element, name, null) ?? true)) { 192 + if (!reference.hasAttribute(name) && (this.options.beforeAttributeUpdated?.(element, name, null) ?? true)) { 251 193 element.removeAttribute(name) 252 - this.#afterAttributeUpdated?.(element, name, value) 194 + this.options.afterAttributeUpdated?.(element, name, value) 253 195 } 254 196 } 255 197 256 198 // Copy attributes from the reference to the element, if they don’t already match. 257 199 for (const { name, value } of reference.attributes) { 258 200 const previousValue = element.getAttribute(name) 259 - if (previousValue !== value && (this.#beforeAttributeUpdated?.(element, name, value) ?? true)) { 201 + if (previousValue !== value && (this.options.beforeAttributeUpdated?.(element, name, value) ?? true)) { 260 202 element.setAttribute(name, value) 261 - this.#afterAttributeUpdated?.(element, name, previousValue) 203 + this.options.afterAttributeUpdated?.(element, name, previousValue) 262 204 } 263 205 } 264 206 265 207 // For certain types of elements, we need to do some extra work to ensure 266 208 // the element’s state matches the reference elements’ state. 267 - if (isInput(element) && isInput(reference)) { 209 + if (isInputElement(element) && isInputElement(reference)) { 268 210 this.#updateProperty(element, "checked", reference.checked) 269 211 this.#updateProperty(element, "disabled", reference.disabled) 270 212 this.#updateProperty(element, "indeterminate", reference.indeterminate) 271 213 if ( 272 214 element.type !== "file" && 273 - !(this.#ignoreActiveValue && document.activeElement === element) && 274 - !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 215 + !(this.options.ignoreActiveValue && document.activeElement === element) && 216 + !(this.options.preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 275 217 ) { 276 218 this.#updateProperty(element, "value", reference.value) 277 219 } 278 - } else if (isOption(element) && isOption(reference)) { 220 + } else if (isOptionElement(element) && isOptionElement(reference)) { 279 221 this.#updateProperty(element, "selected", reference.selected) 280 222 } else if ( 281 - isTextArea(element) && 282 - isTextArea(reference) && 283 - !(this.#ignoreActiveValue && document.activeElement === element) && 284 - !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 223 + isTextAreaElement(element) && 224 + isTextAreaElement(reference) && 225 + !(this.options.ignoreActiveValue && document.activeElement === element) && 226 + !(this.options.preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 285 227 ) { 286 228 this.#updateProperty(element, "value", reference.value) 287 229 ··· 291 233 } 292 234 293 235 // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 294 - #morphChildNodes(pair: MatchingElementReferencePair<Element>): void { 236 + #morphChildNodes(pair: PairOfMatchingElements<Element>): void { 295 237 const [element, reference] = pair 296 238 297 239 const childNodes = element.childNodes ··· 299 241 300 242 for (let i = 0; i < refChildNodes.length; i++) { 301 243 const child = childNodes[i] as ChildNode | null 302 - const refChild = refChildNodes[i] as ReadonlyNode<ChildNode> | null 244 + const refChild = refChildNodes[i] as ChildNode | null 303 245 304 246 if (child && refChild) { 305 - const pair: NodeReferencePair<ChildNode> = [child, refChild] 247 + const pair: PairOfNodes<ChildNode> = [child, refChild] 306 248 307 249 if (isMatchingElementPair(pair)) { 308 - if (isHead(pair[0])) { 309 - this.#morphHeadContents(pair as MatchingElementReferencePair<HTMLHeadElement>) 250 + if (isHeadElement(pair[0])) { 251 + this.#morphHeadContents(pair as PairOfMatchingElements<HTMLHeadElement>) 310 252 } else { 311 253 this.#morphChildElement(pair, element) 312 254 } 313 255 } else this.#morphOtherNode(pair) 314 256 } else if (refChild) { 315 - this.#appendChild(element, refChild.cloneNode(true)) 257 + this.appendChild(element, refChild.cloneNode(true)) 316 258 } 317 259 } 318 260 319 261 // Clean up any excess nodes that may be left over 320 262 while (childNodes.length > refChildNodes.length) { 321 263 const child = element.lastChild 322 - if (child) this.#removeNode(child) 264 + if (child) this.removeNode(child) 323 265 } 324 266 } 325 267 326 - #morphChildElement([child, reference]: MatchingElementReferencePair<Element>, parent: Element): void { 327 - if (!(this.#beforeNodeMorphed?.(child, writableNode(reference)) ?? true)) return 268 + #morphChildElement([child, reference]: PairOfMatchingElements<Element>, parent: Element): void { 269 + if (!(this.options.beforeNodeMorphed?.(child, reference) ?? true)) return 328 270 329 - const refIdSet = this.#idMap.get(reference) 271 + const refIdSet = this.idMap.get(reference) 330 272 331 273 // Generate the array in advance of the loop 332 274 const refSetArray = refIdSet ? [...refIdSet] : [] ··· 345 287 346 288 if (id !== "") { 347 289 if (id === reference.id) { 348 - this.#insertBefore(parent, currentNode, child) 290 + this.moveBefore(parent, currentNode, child) 349 291 return this.#morphNode([currentNode, reference]) 350 292 } else { 351 - const currentIdSet = this.#idMap.get(currentNode) 293 + const currentIdSet = this.idMap.get(currentNode) 352 294 353 295 if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 354 - this.#insertBefore(parent, currentNode, child) 296 + this.moveBefore(parent, currentNode, child) 355 297 return this.#morphNode([currentNode, reference]) 356 298 } 357 299 } ··· 362 304 } 363 305 364 306 // nextMatchByTagName is always set (at minimum to child itself since they have matching tag names) 365 - this.#insertBefore(parent, nextMatchByTagName!, child) 307 + this.moveBefore(parent, nextMatchByTagName!, child) 366 308 this.#morphNode([nextMatchByTagName!, reference]) 367 309 368 - this.#afterNodeMorphed?.(child, writableNode(reference)) 310 + this.options.afterNodeMorphed?.(child, reference) 369 311 } 370 312 371 313 #updateProperty<N extends Node, P extends keyof N>(node: N, propertyName: P, newValue: N[P]): void { 372 314 const previousValue = node[propertyName] 373 315 374 - if (previousValue !== newValue && (this.#beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 316 + if (previousValue !== newValue && (this.options.beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 375 317 node[propertyName] = newValue 376 - this.#afterPropertyUpdated?.(node, propertyName, previousValue) 318 + this.options.afterPropertyUpdated?.(node, propertyName, previousValue) 377 319 } 378 320 } 379 321 380 - #replaceNode(node: ChildNode, newNode: Node): void { 381 - if ((this.#beforeNodeRemoved?.(node) ?? true) && (this.#beforeNodeAdded?.(newNode) ?? true)) { 322 + private replaceNode(node: ChildNode, newNode: Node): void { 323 + if ((this.options.beforeNodeRemoved?.(node) ?? true) && (this.options.beforeNodeAdded?.(newNode) ?? true)) { 382 324 node.replaceWith(newNode) 383 - this.#afterNodeAdded?.(newNode) 384 - this.#afterNodeRemoved?.(node) 325 + this.options.afterNodeAdded?.(newNode) 326 + this.options.afterNodeRemoved?.(node) 385 327 } 386 328 } 387 329 388 - #insertBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode): void { 330 + private moveBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode): void { 389 331 if (node === insertionPoint) return 390 332 391 - // Use moveBefore when available (more efficient for moving existing nodes) 392 - if (supportsMoveBefore) { 393 - ;(parent as any).moveBefore(node, insertionPoint) 333 + if ("moveBefore" in parent && typeof parent.moveBefore === "function") { 334 + parent.moveBefore(node, insertionPoint) 394 335 } else { 395 336 parent.insertBefore(node, insertionPoint) 396 337 } 397 338 } 398 339 399 - #appendChild(node: ParentNode, newNode: Node): void { 400 - if (this.#beforeNodeAdded?.(newNode) ?? true) { 340 + private appendChild(node: ParentNode, newNode: Node): void { 341 + if (this.options.beforeNodeAdded?.(newNode) ?? true) { 401 342 node.appendChild(newNode) 402 - this.#afterNodeAdded?.(newNode) 343 + this.options.afterNodeAdded?.(newNode) 403 344 } 404 345 } 405 346 406 - #removeNode(node: ChildNode): void { 407 - if (this.#beforeNodeRemoved?.(node) ?? true) { 347 + private removeNode(node: ChildNode): void { 348 + if (this.options.beforeNodeRemoved?.(node) ?? true) { 408 349 node.remove() 409 - this.#afterNodeRemoved?.(node) 350 + this.options.afterNodeRemoved?.(node) 410 351 } 411 352 } 412 353 } 413 354 414 - function writableNode<N extends Node>(node: ReadonlyNode<N>): N { 415 - return node as N 416 - } 355 + const parentNodeTypes = new Set([1, 9, 11]) 417 356 418 - function isMatchingElementPair(pair: NodeReferencePair<Node>): pair is MatchingElementReferencePair<Element> { 357 + function isMatchingElementPair(pair: PairOfNodes<Node>): pair is PairOfMatchingElements<Element> { 419 358 const [a, b] = pair 420 359 return isElement(a) && isElement(b) && a.localName === b.localName 421 360 } 422 361 423 - function isParentNodePair(pair: NodeReferencePair<Node>): pair is NodeReferencePair<ParentNode> { 362 + function isParentNodePair(pair: PairOfNodes<Node>): pair is PairOfNodes<ParentNode> { 424 363 return isParentNode(pair[0]) && isParentNode(pair[1]) 425 364 } 426 365 427 - function isElement(node: Node): node is Element 428 - function isElement(node: ReadonlyNode<Node>): node is ReadonlyNode<Element> 429 - function isElement(node: Node | ReadonlyNode<Node>): boolean { 366 + function isElement(node: Node): node is Element { 430 367 return node.nodeType === 1 431 368 } 432 369 433 - function isInput(element: Element): element is HTMLInputElement 434 - function isInput(element: ReadonlyNode<Element>): element is ReadonlyNode<HTMLInputElement> 435 - function isInput(element: Element | ReadonlyNode<Element>): boolean { 370 + function isInputElement(element: Element): element is HTMLInputElement { 436 371 return element.localName === "input" 437 372 } 438 373 439 - function isOption(element: Element): element is HTMLOptionElement 440 - function isOption(element: ReadonlyNode<Element>): element is ReadonlyNode<HTMLOptionElement> 441 - function isOption(element: Element | ReadonlyNode<Element>): boolean { 374 + function isOptionElement(element: Element): element is HTMLOptionElement { 442 375 return element.localName === "option" 443 376 } 444 377 445 - function isTextArea(element: Element): element is HTMLTextAreaElement 446 - function isTextArea(element: ReadonlyNode<Element>): element is ReadonlyNode<HTMLTextAreaElement> 447 - function isTextArea(element: Element | ReadonlyNode<Element>): boolean { 378 + function isTextAreaElement(element: Element): element is HTMLTextAreaElement { 448 379 return element.localName === "textarea" 449 380 } 450 381 451 - function isHead(element: Element): element is HTMLHeadElement 452 - function isHead(element: ReadonlyNode<Element>): element is ReadonlyNode<HTMLHeadElement> 453 - function isHead(element: Element | ReadonlyNode<Element>): boolean { 382 + function isHeadElement(element: Element): element is HTMLHeadElement { 454 383 return element.localName === "head" 455 384 } 456 385 457 - const parentNodeTypes = new Set([1, 9, 11]) 386 + function isParentNode(node: Node): node is ParentNode { 387 + return parentNodeTypes.has(node.nodeType) 388 + } 458 389 459 - function isParentNode(node: Node): node is ParentNode 460 - function isParentNode(node: ReadonlyNode<Node>): node is ReadonlyNode<ParentNode> 461 - function isParentNode(node: Node | ReadonlyNode<Node>): boolean { 462 - return parentNodeTypes.has(node.nodeType) 390 + function isTextNode(node: Node): node is Text { 391 + return node.nodeType === 3 463 392 }