Precise DOM morphing
morphing
typescript
dom
1const SUPPORTS_MOVE_BEFORE = "moveBefore" in Element.prototype
2const ELEMENT_NODE_TYPE = 1
3const TEXT_NODE_TYPE = 3
4
5const IS_PARENT_NODE_TYPE = [
6 0, // 0: (unused)
7 1, // 1: Element
8 0, // 2: Attribute (deprecated)
9 0, // 3: Text
10 0, // 4: CDATASection (deprecated)
11 0, // 5: EntityReference (deprecated)
12 0, // 6: Entity (deprecated)
13 0, // 7: ProcessingInstruction
14 0, // 8: Comment
15 1, // 9: Document
16 0, // 10: DocumentType
17 1, // 11: DocumentFragment
18 0, // 12: Notation (deprecated)
19]
20
21const Operation = {
22 EqualNode: 0,
23 SameElement: 1,
24 SameNode: 2,
25} as const
26
27type Operation = (typeof Operation)[keyof typeof Operation]
28
29const candidateNodes: Set<number> = new Set()
30const candidateElements: Set<number> = new Set()
31const unmatchedNodes: Set<number> = new Set()
32const unmatchedElements: Set<number> = new Set()
33const whitespaceNodes: Set<number> = new Set()
34
35type IdSetMap = WeakMap<Node, Set<string>>
36type IdArrayMap = WeakMap<Node, Array<string>>
37
38/**
39 * Configuration options for morphing operations.
40 */
41export interface Options {
42 /**
43 * When `true`, preserves modified form inputs during morphing.
44 * This prevents user-entered data from being overwritten.
45 * @default false
46 */
47 preserveChanges?: boolean
48
49 /**
50 * Called before a node is visited during morphing.
51 * @param fromNode The existing node in the DOM
52 * @param toNode The new node to morph to
53 * @returns `false` to skip morphing this node, `true` to continue
54 */
55 beforeNodeVisited?: (fromNode: Node, toNode: Node) => boolean
56
57 /**
58 * Called after a node has been visited and morphed.
59 * @param fromNode The morphed node in the DOM
60 * @param toNode The source node that was morphed from
61 */
62 afterNodeVisited?: (fromNode: Node, toNode: Node) => void
63
64 /**
65 * Called before a new node is added to the DOM.
66 * @param parent The parent node where the child will be added
67 * @param node The node to be added
68 * @param insertionPoint The node before which the new node will be inserted, or `null` to append
69 * @returns `false` to prevent adding the node, `true` to continue
70 */
71 beforeNodeAdded?: (parent: ParentNode, node: Node, insertionPoint: ChildNode | null) => boolean
72
73 /**
74 * Called after a node has been added to the DOM.
75 * @param node The node that was added
76 */
77 afterNodeAdded?: (node: Node) => void
78
79 /**
80 * Called before a node is removed from the DOM.
81 * @param node The node to be removed
82 * @returns `false` to prevent removal, `true` to continue
83 */
84 beforeNodeRemoved?: (node: Node) => boolean
85
86 /**
87 * Called after a node has been removed from the DOM.
88 * @param node The node that was removed
89 */
90 afterNodeRemoved?: (node: Node) => void
91
92 /**
93 * Called before an attribute is updated on an element.
94 * @param element The element whose attribute will be updated
95 * @param attributeName The name of the attribute
96 * @param newValue The new value for the attribute, or `null` if being removed
97 * @returns `false` to prevent the update, `true` to continue
98 */
99 beforeAttributeUpdated?: (element: Element, attributeName: string, newValue: string | null) => boolean
100
101 /**
102 * Called after an attribute has been updated on an element.
103 * @param element The element whose attribute was updated
104 * @param attributeName The name of the attribute
105 * @param previousValue The previous value of the attribute, or `null` if it didn't exist
106 */
107 afterAttributeUpdated?: (element: Element, attributeName: string, previousValue: string | null) => void
108
109 /**
110 * Called before an element's children are visited during morphing.
111 * @param parent The parent node whose children will be visited
112 * @returns `false` to skip visiting children, `true` to continue
113 */
114 beforeChildrenVisited?: (parent: ParentNode) => boolean
115
116 /**
117 * Called after an element's children have been visited and morphed.
118 * @param parent The parent node whose children were visited
119 */
120 afterChildrenVisited?: (parent: ParentNode) => void
121}
122
123type NodeWithMoveBefore = ParentNode & {
124 moveBefore: (node: ChildNode, before: ChildNode | null) => void
125}
126
127/**
128 * Morph one document to another. If the `to` document is a string, it will be parsed with a DOMParser.
129 *
130 * @param from The source document to morph from.
131 * @param to The target document or string to morph to.
132 * @param options Optional configuration for the morphing behavior.
133 * @example
134 * ```ts
135 * morphDocument(document, "<html>...</html>", { preserveChanges: true })
136 * ```
137 */
138export function morphDocument(from: Document, to: Document | string, options?: Options): void {
139 if (typeof to === "string") to = parseDocument(to)
140 morph(from.documentElement, to.documentElement, options)
141}
142
143/**
144 * Morph one `ChildNode` to another. If the `to` node is a string, it will be parsed with a `<template>` element.
145 *
146 * @param from The source node to morph from.
147 * @param to The target node, node list or string to morph to.
148 * @example
149 * ```ts
150 * morph(originalDom, newDom)
151 * ```
152 */
153export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void {
154 if (typeof to === "string") to = parseFragment(to).childNodes
155
156 if (!options.preserveChanges && isParentNode(from)) flagDirtyInputs(from)
157
158 new Morph(options).morph(from, to)
159}
160
161/**
162 * Morph the inner content of one ChildNode to the inner content of another.
163 * If the `to` node is a string, it will be parsed with a `<template>` element.
164 *
165 * @param from The source node to morph from.
166 * @param to The target node, node list or string to morph to.
167 * @example
168 * ```ts
169 * morphInner(originalDom, newDom)
170 * ```
171 */
172export function morphInner(from: ChildNode, to: ChildNode | string, options: Options = {}): void {
173 if (typeof to === "string") {
174 const fragment = parseFragment(to)
175
176 if (fragment.firstChild && fragment.childNodes.length === 1 && fragment.firstChild.nodeType === ELEMENT_NODE_TYPE) {
177 to = fragment.firstChild
178 } else {
179 throw new Error("[Morphlex] The string was not a valid HTML element.")
180 }
181 }
182
183 if (
184 from.nodeType === ELEMENT_NODE_TYPE &&
185 to.nodeType === ELEMENT_NODE_TYPE &&
186 (from as Element).localName === (to as Element).localName
187 ) {
188 if (isParentNode(from)) flagDirtyInputs(from)
189 new Morph(options).visitChildNodes(from as Element, to as Element)
190 } else {
191 throw new Error("[Morphlex] You can only do an inner morph with matching elements.")
192 }
193}
194
195function flagDirtyInputs(node: ParentNode): void {
196 for (const input of node.querySelectorAll("input")) {
197 if ((input.name && input.value !== input.defaultValue) || input.checked !== input.defaultChecked) {
198 input.setAttribute("morphlex-dirty", "")
199 }
200 }
201
202 for (const element of node.querySelectorAll("option")) {
203 if (element.value && element.selected !== element.defaultSelected) {
204 element.setAttribute("morphlex-dirty", "")
205 }
206 }
207
208 for (const element of node.querySelectorAll("textarea")) {
209 if (element.value !== element.defaultValue) {
210 element.setAttribute("morphlex-dirty", "")
211 }
212 }
213}
214
215function parseFragment(string: string): DocumentFragment {
216 const template = document.createElement("template")
217 template.innerHTML = string.trim()
218
219 return template.content
220}
221
222function parseDocument(string: string): Document {
223 const parser = new DOMParser()
224 return parser.parseFromString(string.trim(), "text/html")
225}
226
227function moveBefore(parent: ParentNode, node: ChildNode, insertionPoint: ChildNode | null): void {
228 if (node === insertionPoint) return
229 if (node.parentNode === parent) {
230 if (node.nextSibling === insertionPoint) return
231 if (SUPPORTS_MOVE_BEFORE) {
232 ;(parent as NodeWithMoveBefore).moveBefore(node, insertionPoint)
233 return
234 }
235 }
236
237 parent.insertBefore(node, insertionPoint)
238}
239
240class Morph {
241 readonly #idArrayMap: IdArrayMap = new WeakMap()
242 readonly #idSetMap: IdSetMap = new WeakMap()
243 readonly #options: Options
244
245 constructor(options: Options = {}) {
246 this.#options = options
247 }
248
249 // Find longest increasing subsequence to minimize moves during reordering
250 // Returns the indices in the sequence that form the LIS
251 #longestIncreasingSubsequence(sequence: Array<number | undefined>): Array<number> {
252 const n = sequence.length
253 if (n === 0) return []
254
255 // smallestEnding[i] = smallest ending value of any increasing subsequence of length i+1
256 const smallestEnding: Array<number> = []
257 // indices[i] = index in sequence where smallestEnding[i] occurs
258 const indices: Array<number> = []
259 // prev[i] = previous index in the LIS ending at sequence[i]
260 const prev: Array<number> = new Array(n)
261
262 // Build the LIS by processing each value
263 for (let i = 0; i < n; i++) {
264 const val = sequence[i]
265 if (val === undefined) continue // Skip new nodes (not in original sequence)
266
267 // Binary search: find where this value fits in smallestEnding
268 let left = 0
269 let right = smallestEnding.length
270
271 while (left < right) {
272 const mid = Math.floor((left + right) / 2)
273 if (smallestEnding[mid]! < val) left = mid + 1
274 else right = mid
275 }
276
277 // Link this element to the previous one in the subsequence
278 prev[i] = left > 0 ? indices[left - 1]! : -1
279
280 // Either extend the sequence or update an existing position
281 if (left === smallestEnding.length) {
282 // Extend: this value is larger than all previous endings
283 smallestEnding.push(val)
284 indices.push(i)
285 } else {
286 // Update: found a better (smaller) ending for this length
287 smallestEnding[left] = val
288 indices[left] = i
289 }
290 }
291
292 // Reconstruct the actual indices that form the LIS
293 const result: Array<number> = []
294 if (indices.length === 0) return result
295
296 // Walk backwards through prev links to build the LIS
297 let curr: number | undefined = indices[indices.length - 1]
298 while (curr !== undefined && curr !== -1) {
299 result.unshift(curr)
300 curr = prev[curr]
301 }
302
303 return result
304 }
305
306 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void {
307 if (isParentNode(from)) {
308 this.#mapIdSets(from)
309 }
310
311 if (to instanceof NodeList) {
312 this.#mapIdArraysForEach(to)
313 this.#morphOneToMany(from, to)
314 } else if (isParentNode(to)) {
315 this.#mapIdArrays(to)
316 this.#morphOneToOne(from, to)
317 }
318 }
319
320 #morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void {
321 const length = to.length
322
323 if (length === 0) {
324 this.#removeNode(from)
325 } else if (length === 1) {
326 this.#morphOneToOne(from, to[0]!)
327 } else if (length > 1) {
328 const newNodes = [...to]
329 this.#morphOneToOne(from, newNodes.shift()!)
330 const insertionPoint = from.nextSibling
331 const parent = from.parentNode || document
332
333 for (let i = 0; i < newNodes.length; i++) {
334 const newNode = newNodes[i]!
335 if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) {
336 parent.insertBefore(newNode, insertionPoint)
337 this.#options.afterNodeAdded?.(newNode)
338 }
339 }
340 }
341 }
342
343 #morphOneToOne(from: ChildNode, to: ChildNode): void {
344 // Fast path: if nodes are exactly the same object, skip morphing
345 if (from === to) return
346 if (from.isEqualNode(to)) return
347
348 if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) {
349 if ((from as Element).localName === (to as Element).localName) {
350 this.#morphMatchingElements(from as Element, to as Element)
351 } else {
352 this.#morphNonMatchingElements(from as Element, to as Element)
353 }
354 } else {
355 this.#morphOtherNode(from, to)
356 }
357 }
358
359 #morphMatchingElements(from: Element, to: Element): void {
360 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
361
362 if (from.hasAttributes() || to.hasAttributes()) {
363 this.#visitAttributes(from, to)
364 }
365
366 if ("textarea" === from.localName && "textarea" === to.localName) {
367 this.#visitTextArea(from as HTMLTextAreaElement, to as HTMLTextAreaElement)
368 } else if (from.hasChildNodes() || to.hasChildNodes()) {
369 this.visitChildNodes(from, to)
370 }
371
372 this.#options.afterNodeVisited?.(from, to)
373 }
374
375 #morphNonMatchingElements(from: Element, to: Element): void {
376 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
377
378 this.#replaceNode(from, to)
379
380 this.#options.afterNodeVisited?.(from, to)
381 }
382
383 #morphOtherNode(from: ChildNode, to: ChildNode): void {
384 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
385
386 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) {
387 from.nodeValue = to.nodeValue
388 } else {
389 this.#replaceNode(from, to)
390 }
391
392 this.#options.afterNodeVisited?.(from, to)
393 }
394
395 #visitAttributes(from: Element, to: Element): void {
396 if (from.hasAttribute("morphlex-dirty")) {
397 from.removeAttribute("morphlex-dirty")
398 }
399
400 // First pass: update/add attributes from reference (iterate forwards)
401 for (const { name, value } of to.attributes) {
402 if (name === "value") {
403 if (isInputElement(from) && from.value !== value) {
404 if (!this.#options.preserveChanges || from.value === from.defaultValue) {
405 from.value = value
406 }
407 }
408 }
409
410 if (name === "selected") {
411 if (isOptionElement(from) && !from.selected) {
412 if (!this.#options.preserveChanges || from.selected === from.defaultSelected) {
413 from.selected = true
414 }
415 }
416 }
417
418 if (name === "checked") {
419 if (isInputElement(from) && !from.checked) {
420 if (!this.#options.preserveChanges || from.checked === from.defaultChecked) {
421 from.checked = true
422 }
423 }
424 }
425
426 const oldValue = from.getAttribute(name)
427
428 if (oldValue !== value && (this.#options.beforeAttributeUpdated?.(from, name, value) ?? true)) {
429 from.setAttribute(name, value)
430 this.#options.afterAttributeUpdated?.(from, name, oldValue)
431 }
432 }
433
434 // Second pass: remove excess attributes
435 for (const { name, value } of from.attributes) {
436 if (!to.hasAttribute(name)) {
437 if (name === "selected") {
438 if (isOptionElement(from) && from.selected) {
439 if (!this.#options.preserveChanges || from.selected === from.defaultSelected) {
440 from.selected = false
441 }
442 }
443 }
444
445 if (name === "checked") {
446 if (isInputElement(from) && from.checked) {
447 if (!this.#options.preserveChanges || from.checked === from.defaultChecked) {
448 from.checked = false
449 }
450 }
451 }
452
453 if (this.#options.beforeAttributeUpdated?.(from, name, null) ?? true) {
454 from.removeAttribute(name)
455 this.#options.afterAttributeUpdated?.(from, name, value)
456 }
457 }
458 }
459 }
460
461 #visitTextArea(from: HTMLTextAreaElement, to: HTMLTextAreaElement): void {
462 const newTextContent = to.textContent || ""
463 const isModified = from.value !== from.defaultValue
464
465 // Update text content (which updates defaultValue)
466 if (from.textContent !== newTextContent) {
467 from.textContent = newTextContent
468 }
469
470 if (this.#options.preserveChanges && isModified) return
471
472 from.value = from.defaultValue
473 }
474
475 visitChildNodes(from: Element, to: Element): void {
476 if (!(this.#options.beforeChildrenVisited?.(from) ?? true)) return
477 const parent = from
478
479 const fromChildNodes = [...from.childNodes]
480 const toChildNodes = [...to.childNodes]
481
482 candidateNodes.clear()
483 candidateElements.clear()
484 unmatchedNodes.clear()
485 unmatchedElements.clear()
486 whitespaceNodes.clear()
487
488 const seq: Array<number> = []
489 const matches: Array<number> = []
490 const op: Array<Operation> = []
491 const nodeTypeMap: Array<number> = []
492 const candidateNodeTypeMap: Array<number> = []
493 const localNameMap: Array<string> = []
494 const candidateLocalNameMap: Array<string> = []
495
496 for (let i = 0; i < fromChildNodes.length; i++) {
497 const candidate = fromChildNodes[i]!
498 const nodeType = candidate.nodeType
499 candidateNodeTypeMap[i] = nodeType
500
501 if (nodeType === ELEMENT_NODE_TYPE) {
502 candidateLocalNameMap[i] = (candidate as Element).localName
503 candidateElements.add(i)
504 } else if (nodeType === TEXT_NODE_TYPE && candidate.textContent?.trim() === "") {
505 whitespaceNodes.add(i)
506 } else {
507 candidateNodes.add(i)
508 }
509 }
510
511 for (let i = 0; i < toChildNodes.length; i++) {
512 const node = toChildNodes[i]!
513 const nodeType = node.nodeType
514 nodeTypeMap[i] = nodeType
515
516 if (nodeType === ELEMENT_NODE_TYPE) {
517 localNameMap[i] = (node as Element).localName
518 unmatchedElements.add(i)
519 } else if (nodeType === TEXT_NODE_TYPE && node.textContent?.trim() === "") {
520 continue
521 } else {
522 unmatchedNodes.add(i)
523 }
524 }
525
526 // Match elements by isEqualNode
527 for (const unmatchedIndex of unmatchedElements) {
528 const localName = localNameMap[unmatchedIndex]
529 const element = toChildNodes[unmatchedIndex] as Element
530
531 for (const candidateIndex of candidateElements) {
532 if (localName !== candidateLocalNameMap[candidateIndex]) continue
533 const candidate = fromChildNodes[candidateIndex] as Element
534
535 if (candidate.isEqualNode(element)) {
536 matches[unmatchedIndex] = candidateIndex
537 op[unmatchedIndex] = Operation.EqualNode
538 seq[candidateIndex] = unmatchedIndex
539 candidateElements.delete(candidateIndex)
540 unmatchedElements.delete(unmatchedIndex)
541 break
542 }
543 }
544 }
545
546 // Match by exact id or idSet
547 for (const unmatchedIndex of unmatchedElements) {
548 const element = toChildNodes[unmatchedIndex] as Element
549
550 const id = element.id
551 const idArray = this.#idArrayMap.get(element)
552
553 if (id === "" && !idArray) continue
554
555 candidateLoop: for (const candidateIndex of candidateElements) {
556 const candidate = fromChildNodes[candidateIndex] as Element
557
558 if (localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex]) {
559 // Match by exact id
560 if (id !== "" && id === candidate.id) {
561 matches[unmatchedIndex] = candidateIndex
562 op[unmatchedIndex] = Operation.SameElement
563 seq[candidateIndex] = unmatchedIndex
564 candidateElements.delete(candidateIndex)
565 unmatchedElements.delete(unmatchedIndex)
566 break candidateLoop
567 }
568
569 // Match by idArray (to) against idSet (from)
570 if (idArray) {
571 const candidateIdSet = this.#idSetMap.get(candidate)
572 if (candidateIdSet) {
573 for (let i = 0; i < idArray.length; i++) {
574 const arrayId = idArray[i]!
575 if (candidateIdSet.has(arrayId)) {
576 matches[unmatchedIndex] = candidateIndex
577 op[unmatchedIndex] = Operation.SameElement
578 seq[candidateIndex] = unmatchedIndex
579 candidateElements.delete(candidateIndex)
580 unmatchedElements.delete(unmatchedIndex)
581 break candidateLoop
582 }
583 }
584 }
585 }
586 }
587 }
588 }
589
590 // Match by heuristics
591 for (const unmatchedIndex of unmatchedElements) {
592 const element = toChildNodes[unmatchedIndex] as Element
593
594 const name = element.getAttribute("name")
595 const href = element.getAttribute("href")
596 const src = element.getAttribute("src")
597
598 for (const candidateIndex of candidateElements) {
599 const candidate = fromChildNodes[candidateIndex] as Element
600 if (
601 localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] &&
602 ((name && name === candidate.getAttribute("name")) ||
603 (href && href === candidate.getAttribute("href")) ||
604 (src && src === candidate.getAttribute("src")))
605 ) {
606 matches[unmatchedIndex] = candidateIndex
607 seq[candidateIndex] = unmatchedIndex
608 op[unmatchedIndex] = Operation.SameElement
609 candidateElements.delete(candidateIndex)
610 unmatchedElements.delete(unmatchedIndex)
611 break
612 }
613 }
614 }
615
616 // Match by tagName
617 for (const unmatchedIndex of unmatchedElements) {
618 const element = toChildNodes[unmatchedIndex] as Element
619
620 const localName = localNameMap[unmatchedIndex]
621
622 for (const candidateIndex of candidateElements) {
623 const candidate = fromChildNodes[candidateIndex] as Element
624 const candidateLocalName = candidateLocalNameMap[candidateIndex]
625
626 if (localName === candidateLocalName) {
627 if (localName === "input" && (candidate as HTMLInputElement).type !== (element as HTMLInputElement).type) {
628 // Treat inputs with different type as though they are different tags.
629 continue
630 }
631 matches[unmatchedIndex] = candidateIndex
632 seq[candidateIndex] = unmatchedIndex
633 op[unmatchedIndex] = Operation.SameElement
634 candidateElements.delete(candidateIndex)
635 unmatchedElements.delete(unmatchedIndex)
636 break
637 }
638 }
639 }
640
641 // Match nodes by isEqualNode (skip whitespace-only text nodes)
642 for (const unmatchedIndex of unmatchedNodes) {
643 const node = toChildNodes[unmatchedIndex]!
644
645 for (const candidateIndex of candidateNodes) {
646 const candidate = fromChildNodes[candidateIndex]!
647 if (candidate.isEqualNode(node)) {
648 matches[unmatchedIndex] = candidateIndex
649 op[unmatchedIndex] = Operation.EqualNode
650 seq[candidateIndex] = unmatchedIndex
651 candidateNodes.delete(candidateIndex)
652 unmatchedNodes.delete(unmatchedIndex)
653 break
654 }
655 }
656 }
657
658 // Match by nodeType
659 for (const unmatchedIndex of unmatchedNodes) {
660 const nodeType = nodeTypeMap[unmatchedIndex]
661
662 for (const candidateIndex of candidateNodes) {
663 if (nodeType === candidateNodeTypeMap[candidateIndex]) {
664 matches[unmatchedIndex] = candidateIndex
665 op[unmatchedIndex] = Operation.SameNode
666 seq[candidateIndex] = unmatchedIndex
667 candidateNodes.delete(candidateIndex)
668 unmatchedNodes.delete(unmatchedIndex)
669 break
670 }
671 }
672 }
673
674 // Remove any unmatched candidates first, before calculating LIS and repositioning
675 for (const i of candidateNodes) this.#removeNode(fromChildNodes[i]!)
676 for (const i of whitespaceNodes) this.#removeNode(fromChildNodes[i]!)
677 for (const i of candidateElements) this.#removeNode(fromChildNodes[i]!)
678
679 // Find LIS - these nodes don't need to move
680 // matches already contains the fromChildNodes indices, so we can use it directly
681 const lisIndices = this.#longestIncreasingSubsequence(matches)
682
683 const shouldNotMove: Array<boolean> = new Array(fromChildNodes.length)
684 for (let i = 0; i < lisIndices.length; i++) {
685 shouldNotMove[matches[lisIndices[i]!]!] = true
686 }
687
688 let insertionPoint: ChildNode | null = parent.firstChild
689 for (let i = 0; i < toChildNodes.length; i++) {
690 const node = toChildNodes[i]!
691 const matchInd = matches[i]
692 if (matchInd !== undefined) {
693 const match = fromChildNodes[matchInd]!
694 const operation = op[matchInd]!
695
696 if (!shouldNotMove[matchInd]) {
697 moveBefore(parent, match, insertionPoint)
698 }
699
700 if (operation === Operation.EqualNode) {
701 } else if (operation === Operation.SameElement) {
702 // this.#morphMatchingElements(match as Element, node as Element)
703 this.#morphMatchingElements(match as Element, node as Element)
704 } else {
705 this.#morphOneToOne(match, node)
706 }
707
708 insertionPoint = match.nextSibling
709 } else {
710 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {
711 parent.insertBefore(node, insertionPoint)
712 this.#options.afterNodeAdded?.(node)
713 insertionPoint = node.nextSibling
714 }
715 }
716 }
717
718 this.#options.afterChildrenVisited?.(from)
719 }
720
721 #replaceNode(node: ChildNode, newNode: ChildNode): void {
722 const parent = node.parentNode || document
723 const insertionPoint = node
724 // Check if both removal and addition are allowed before starting the replacement
725 if (
726 (this.#options.beforeNodeRemoved?.(node) ?? true) &&
727 (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true)
728 ) {
729 parent.insertBefore(newNode, insertionPoint)
730 this.#options.afterNodeAdded?.(newNode)
731 node.remove()
732 this.#options.afterNodeRemoved?.(node)
733 }
734 }
735
736 #removeNode(node: ChildNode): void {
737 if (this.#options.beforeNodeRemoved?.(node) ?? true) {
738 node.remove()
739 this.#options.afterNodeRemoved?.(node)
740 }
741 }
742
743 #mapIdArraysForEach(nodeList: NodeList): void {
744 for (const childNode of nodeList) {
745 if (isParentNode(childNode)) {
746 this.#mapIdArrays(childNode)
747 }
748 }
749 }
750
751 // For each node with an ID, push that ID into the IdArray on the IdArrayMap, for each of its parent elements.
752 #mapIdArrays(node: ParentNode): void {
753 const idArrayMap = this.#idArrayMap
754
755 for (const element of node.querySelectorAll("[id]")) {
756 const id = element.id
757
758 if (id === "") continue
759
760 let currentElement: Element | null = element
761
762 while (currentElement) {
763 const idArray = idArrayMap.get(currentElement)
764 if (idArray) {
765 idArray.push(id)
766 } else {
767 idArrayMap.set(currentElement, [id])
768 }
769 if (currentElement === node) break
770 currentElement = currentElement.parentElement
771 }
772 }
773 }
774
775 // For each node with an ID, add that ID into the IdSet on the IdSetMap, for each of its parent elements.
776 #mapIdSets(node: ParentNode): void {
777 const idSetMap = this.#idSetMap
778
779 for (const element of node.querySelectorAll("[id]")) {
780 const id = element.id
781
782 if (id === "") continue
783
784 let currentElement: Element | null = element
785
786 while (currentElement) {
787 const idSet = idSetMap.get(currentElement)
788 if (idSet) {
789 idSet.add(id)
790 } else {
791 idSetMap.set(currentElement, new Set([id]))
792 }
793 if (currentElement === node) break
794 currentElement = currentElement.parentElement
795 }
796 }
797 }
798}
799
800function isInputElement(element: Element): element is HTMLInputElement {
801 return element.localName === "input"
802}
803
804function isOptionElement(element: Element): element is HTMLOptionElement {
805 return element.localName === "option"
806}
807
808function isParentNode(node: Node): node is ParentNode {
809 return !!IS_PARENT_NODE_TYPE[node.nodeType]
810}