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