Something went wrong. Try again.
Precise DOM morphing
morphing typescript dom
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083const SUPPORTS_MOVE_BEFORE = "moveBefore" in Element.prototypeconst ELEMENT_NODE_TYPE = 1const TEXT_NODE_TYPE = 3const TREE_WALKER_SHOW_ELEMENT = 1
const IS_PARENT_NODE_TYPE = [ 0, // 0: (unused) 1, // 1: Element 0, // 2: Attribute (deprecated) 0, // 3: Text 0, // 4: CDATASection (deprecated) 0, // 5: EntityReference (deprecated) 0, // 6: Entity (deprecated) 0, // 7: ProcessingInstruction 0, // 8: Comment 1, // 9: Document 0, // 10: DocumentType 1, // 11: DocumentFragment 0, // 12: Notation (deprecated)]
const Operation = { EqualNode: 0, SameElement: 1, SameNode: 2,} as const
type Operation = (typeof Operation)[keyof typeof Operation]
type IdSetMap = WeakMap<Node, Set<string>>type IdArrayMap = WeakMap<Node, Array<string>>type CandidateIdBucket = number | Array<number>
/** * Configuration options for morphing operations. */export interface Options { /** * When `true`, preserves modified form inputs during morphing. * This prevents user-entered data from being overwritten. * @default false */ preserveChanges?: boolean
/** * Called before a node is visited during morphing. * @param fromNode The existing node in the DOM * @param toNode The new node to morph to * @returns `false` to skip morphing this node, `true` to continue */ beforeNodeVisited?: (fromNode: Node, toNode: Node) => boolean
/** * Called after a node has been visited and morphed. * @param fromNode The morphed node in the DOM * @param toNode The source node that was morphed from */ afterNodeVisited?: (fromNode: Node, toNode: Node) => void
/** * Called before a new node is added to the DOM. * @param parent The parent node where the child will be added * @param node The node to be added * @param insertionPoint The node before which the new node will be inserted, or `null` to append * @returns `false` to prevent adding the node, `true` to continue */ beforeNodeAdded?: (parent: ParentNode, node: Node, insertionPoint: ChildNode | null) => boolean
/** * Called after a node has been added to the DOM. * @param node The node that was added */ afterNodeAdded?: (node: Node) => void
/** * Called before a node is removed from the DOM. * @param node The node to be removed * @returns `false` to prevent removal, `true` to continue */ beforeNodeRemoved?: (node: Node) => boolean
/** * Called after a node has been removed from the DOM. * @param node The node that was removed */ afterNodeRemoved?: (node: Node) => void
/** * Called before an attribute is updated on an element. * @param element The element whose attribute will be updated * @param attributeName The name of the attribute * @param newValue The new value for the attribute, or `null` if being removed * @returns `false` to prevent the update, `true` to continue */ beforeAttributeUpdated?: (element: Element, attributeName: string, newValue: string | null) => boolean
/** * Called after an attribute has been updated on an element. * @param element The element whose attribute was updated * @param attributeName The name of the attribute * @param previousValue The previous value of the attribute, or `null` if it didn't exist */ afterAttributeUpdated?: (element: Element, attributeName: string, previousValue: string | null) => void
/** * Called before an element's children are visited during morphing. * @param parent The parent node whose children will be visited * @returns `false` to skip visiting children, `true` to continue */ beforeChildrenVisited?: (parent: ParentNode) => boolean
/** * Called after an element's children have been visited and morphed. * @param parent The parent node whose children were visited */ afterChildrenVisited?: (parent: ParentNode) => void}
type NodeWithMoveBefore = ParentNode & { moveBefore: (node: ChildNode, before: ChildNode | null) => void}
/** * Morph one document to another. If the `to` document is a string, it will be parsed with a DOMParser. * * @param from The source document to morph from. * @param to The target document or string to morph to. * @param options Optional configuration for the morphing behavior. * @example * ```ts * morphDocument(document, "<html>...</html>", { preserveChanges: true }) * ``` * * @remarks * **Security:** When `to` is a string, it is parsed as HTML and nodes from the parsed * tree are inserted into the live document. Inline event handler attributes (e.g. * `onclick`) and resource-loading attributes (e.g. `src`, `href`) take effect once * the nodes are adopted. Do not pass untrusted HTML; sanitize it first. */export function morphDocument(from: Document, to: Document | string, options?: Options): void { if (typeof to === "string") to = parseDocument(to) morph(from.documentElement, to.documentElement, options)}
/** * Morph one `ChildNode` to another. If the `to` node is a string, it will be parsed with a `<template>` element. * * @param from The source node to morph from. * @param to The target node, node list or string to morph to. * @example * ```ts * morph(originalDom, newDom) * ``` * * @remarks * **Security:** When `to` is a string, it is parsed as HTML and nodes from the parsed * tree are inserted into the live document. Inline event handler attributes (e.g. * `onclick`) and resource-loading attributes (e.g. `src`, `href`) take effect once * the nodes are adopted. Do not pass untrusted HTML; sanitize it first. */export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void { if (typeof to === "string") to = parseFragment(to).childNodes
const flagged = isParentNode(from) ? flagDirtyInputs(from as Element) : null new Morph(options).morph(from, to) if (flagged) clearDirtyFlags(flagged)}
/** * Morph the inner content of one ChildNode to the inner content of another. * If the `to` node is a string, it will be parsed with a `<template>` element. * * @param from The source node to morph from. * @param to The target node, node list or string to morph to. * @example * ```ts * morphInner(originalDom, newDom) * ``` * * @remarks * **Security:** When `to` is a string, it is parsed as HTML and nodes from the parsed * tree are inserted into the live document. Inline event handler attributes (e.g. * `onclick`) and resource-loading attributes (e.g. `src`, `href`) take effect once * the nodes are adopted. Do not pass untrusted HTML; sanitize it first. */export function morphInner(from: ChildNode, to: ChildNode | string, options: Options = {}): void { if (typeof to === "string") { const fragment = parseFragment(to)
if (fragment.firstChild && fragment.childNodes.length === 1 && fragment.firstChild.nodeType === ELEMENT_NODE_TYPE) { to = fragment.firstChild } else { throw new Error("[Morphlex] The string was not a valid HTML element.") } }
if ( from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE && (from as Element).localName === (to as Element).localName && (from as Element).namespaceURI === (to as Element).namespaceURI ) { const fromElement = from as Element const flagged = flagDirtyInputs(fromElement) new Morph(options).visitChildNodes(fromElement, to as Element) clearDirtyFlags(flagged) } else { throw new Error("[Morphlex] You can only do an inner morph with matching elements.") }}
function flagDirtyInputs(node: Element): Array<Element> { const flagged: Array<Element> = []
if (isInputElement(node)) { if (node.value !== node.defaultValue || node.checked !== node.defaultChecked) { node.setAttribute("morphlex-dirty", "") flagged.push(node) } } else if (isOptionElement(node)) { if (node.selected !== node.defaultSelected) { node.setAttribute("morphlex-dirty", "") flagged.push(node) } } else if (node.localName === "textarea") { const textarea = node as HTMLTextAreaElement if (textarea.value !== textarea.defaultValue) { textarea.setAttribute("morphlex-dirty", "") flagged.push(textarea) } }
for (const input of node.querySelectorAll("input")) { if (input.value !== input.defaultValue || input.checked !== input.defaultChecked) { input.setAttribute("morphlex-dirty", "") flagged.push(input) } }
for (const element of node.querySelectorAll("option")) { if (element.selected !== element.defaultSelected) { element.setAttribute("morphlex-dirty", "") flagged.push(element) } }
for (const element of node.querySelectorAll("textarea")) { if (element.value !== element.defaultValue) { element.setAttribute("morphlex-dirty", "") flagged.push(element) } }
return flagged}
function clearDirtyFlags(elements: Array<Element>): void { for (let i = 0; i < elements.length; i++) { const element = elements[i]! if (element.hasAttribute("morphlex-dirty")) { element.removeAttribute("morphlex-dirty") } }}
function parseFragment(string: string): DocumentFragment { const template = document.createElement("template") template.innerHTML = string trimFragmentEdgeWhitespace(template.content)
return template.content}
function parseDocument(string: string): Document { const parser = new DOMParser() return parser.parseFromString(string.trim(), "text/html")}
/* v8 ignore start -- reorder fast paths are environment-sensitive */function moveBefore(parent: ParentNode, node: ChildNode, insertionPoint: ChildNode | null): void { if (node === insertionPoint) return if (node.parentNode === parent) { if (node.nextSibling === insertionPoint) return if (SUPPORTS_MOVE_BEFORE) { ;(parent as NodeWithMoveBefore).moveBefore(node, insertionPoint) return } } parent.insertBefore(node, insertionPoint)}/* v8 ignore stop */
class Morph { readonly #idArrayMap: IdArrayMap = new WeakMap() readonly #idSetMap: IdSetMap = new WeakMap() readonly #options: Options
constructor(options: Options = {}) { this.#options = options }
morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { if (isParentNode(from)) { this.#mapIdSets(from) }
if (isNodeList(to)) { this.#mapIdArraysForEach(to) this.#morphOneToMany(from, to) } else { if (isParentNode(to)) { this.#mapIdArrays(to) } this.#morphOneToOne(from, to) } }
#morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void { const length = to.length
if (length === 0) { this.#removeNode(from) } else if (length === 1) { this.#morphOneToOne(from, to[0]!) } else { const newNodes = [...to] const insertionPoint = from.nextSibling const parent = from.parentNode this.#morphOneToOne(from, newNodes.shift()!)
if (!parent) { for (let i = 0; i < newNodes.length; i++) { this.#options.beforeNodeAdded?.(document, newNodes[i]!, from) } return }
for (let i = 0; i < newNodes.length; i++) { const newNode = newNodes[i]! if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) { parent.insertBefore(newNode, insertionPoint) this.#options.afterNodeAdded?.(newNode) } } } }
#morphOneToOne(from: ChildNode, to: ChildNode): void { // Fast path: if nodes are exactly the same object, skip morphing if (from === to) return if (from.isEqualNode(to)) return
if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { if (canMorphElementInPlace(from as Element, to as Element)) { this.#morphMatchingElements(from as Element, to as Element) } else { this.#morphNonMatchingElements(from as Element, to as Element) } } else { this.#morphOtherNode(from, to) } }
#morphMatchingElements(from: Element, to: Element): void { if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
if (from.hasAttributes() || to.hasAttributes()) { this.#visitAttributes(from, to) }
if ("textarea" === from.localName && "textarea" === to.localName) { this.#visitTextArea(from as HTMLTextAreaElement, to as HTMLTextAreaElement) } else if (from.hasChildNodes() || to.hasChildNodes()) { this.visitChildNodes(from, to) }
this.#options.afterNodeVisited?.(from, to) }
#morphNonMatchingElements(from: Element, to: Element): void { if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
this.#replaceNode(from, to)
this.#options.afterNodeVisited?.(from, to) }
#morphOtherNode(from: ChildNode, to: ChildNode): void { if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
const fromValue = from.nodeValue const toValue = to.nodeValue
if (from.nodeType === to.nodeType && fromValue !== null && toValue !== null) { from.nodeValue = toValue } else { this.#replaceNode(from, to) }
this.#options.afterNodeVisited?.(from, to) }
#visitAttributes(from: Element, to: Element): void { if (from.hasAttribute("morphlex-dirty")) { from.removeAttribute("morphlex-dirty") }
// First pass: update/add attributes from reference (iterate forwards) for (const { name, value } of to.attributes) { if (name === "morphlex-dirty") continue if (name === "value") { if (isInputElement(from) && from.type !== "file" && from.value !== value) { if (!this.#options.preserveChanges) { from.value = value } } }
if (name === "selected") { if (isOptionElement(from) && !from.selected) { if (!this.#options.preserveChanges) { from.selected = true } } }
if (name === "checked") { if (isInputElement(from) && !from.checked) { if (!this.#options.preserveChanges) { from.checked = true } } }
const oldValue = from.getAttribute(name)
if (oldValue !== value && (this.#options.beforeAttributeUpdated?.(from, name, value) ?? true)) { from.setAttribute(name, value) this.#options.afterAttributeUpdated?.(from, name, oldValue) } }
// Second pass: remove excess attributes for (const { name, value } of Array.from(from.attributes)) { if (!to.hasAttribute(name)) { if (name === "selected") { if (isOptionElement(from) && from.selected) { if (!this.#options.preserveChanges) { from.selected = false } } }
if (name === "checked") { if (isInputElement(from) && from.checked) { if (!this.#options.preserveChanges) { from.checked = false } } }
if (this.#options.beforeAttributeUpdated?.(from, name, null) ?? true) { from.removeAttribute(name) this.#options.afterAttributeUpdated?.(from, name, value) } } } }
#visitTextArea(from: HTMLTextAreaElement, to: HTMLTextAreaElement): void { const newTextContent = to.textContent || "" const isModified = from.value !== from.defaultValue
// Update text content (which updates defaultValue) if (from.textContent !== newTextContent) { from.textContent = newTextContent }
if (this.#options.preserveChanges && isModified) return
from.value = from.defaultValue }
visitChildNodes(from: Element, to: Element): void { if (!(this.#options.beforeChildrenVisited?.(from) ?? true)) return const parent = from
const fromChildNodes = nodeListToArray(from.childNodes) const toChildNodes = nodeListToArray(to.childNodes)
const candidateNodeIndices: Array<number> = [] const candidateElementIndices: Array<number> = [] const candidateElementWithIdIndices: Array<number> = [] const candidateElementIndicesById: Map<string, CandidateIdBucket> = new Map() const unmatchedNodeIndices: Array<number> = [] const unmatchedElementIndices: Array<number> = [] const whitespaceNodeIndices: Array<number> = []
const candidateNodeActive = new Uint8Array(fromChildNodes.length) const candidateElementActive = new Uint8Array(fromChildNodes.length) const candidateElementWithIdActive = new Uint8Array(fromChildNodes.length) const unmatchedNodeActive = new Uint8Array(toChildNodes.length) const unmatchedElementActive = new Uint8Array(toChildNodes.length)
const matches: Array<number> = [] const op: Array<Operation> = [] const nodeTypeMap: Array<number> = [] const candidateNodeTypeMap: Array<number> = [] const localNameMap: Array<string> = [] const candidateLocalNameMap: Array<string> = [] const namespaceURIMap: Array<string | null> = [] const candidateNamespaceURIMap: Array<string | null> = []
for (let i = 0; i < fromChildNodes.length; i++) { const candidate = fromChildNodes[i]! const nodeType = candidate.nodeType candidateNodeTypeMap[i] = nodeType
if (nodeType === ELEMENT_NODE_TYPE) { const candidateElement = candidate as Element candidateLocalNameMap[i] = candidateElement.localName candidateNamespaceURIMap[i] = candidateElement.namespaceURI const candidateId = candidateElement.id if (candidateId !== "") { candidateElementWithIdActive[i] = 1 candidateElementWithIdIndices.push(i)
const existingBucket = candidateElementIndicesById.get(candidateId) if (existingBucket === undefined) { candidateElementIndicesById.set(candidateId, i) } else if (Array.isArray(existingBucket)) { existingBucket.push(i) } else { candidateElementIndicesById.set(candidateId, [existingBucket, i]) } } else { candidateElementActive[i] = 1 candidateElementIndices.push(i) } } else if (isWhitespaceTextNode(candidate)) { whitespaceNodeIndices.push(i) } else { candidateNodeActive[i] = 1 candidateNodeIndices.push(i) } }
for (let i = 0; i < toChildNodes.length; i++) { const node = toChildNodes[i]! const nodeType = node.nodeType nodeTypeMap[i] = nodeType
if (nodeType === ELEMENT_NODE_TYPE) { const element = node as Element localNameMap[i] = element.localName namespaceURIMap[i] = element.namespaceURI unmatchedElementActive[i] = 1 unmatchedElementIndices.push(i) } else if (isWhitespaceTextNode(node)) { continue } else { unmatchedNodeActive[i] = 1 unmatchedNodeIndices.push(i) } }
// Match elements by isEqualNode for (let i = 0; i < unmatchedElementIndices.length; i++) { const unmatchedIndex = unmatchedElementIndices[i]!
const localName = localNameMap[unmatchedIndex] const element = toChildNodes[unmatchedIndex] as Element
for (let c = 0; c < candidateElementIndices.length; c++) { const candidateIndex = candidateElementIndices[c]! if (!candidateElementActive[candidateIndex]) continue if (localName !== candidateLocalNameMap[candidateIndex]) continue if (namespaceURIMap[unmatchedIndex] !== candidateNamespaceURIMap[candidateIndex]) continue const candidate = fromChildNodes[candidateIndex] as Element
if (candidate.isEqualNode(element)) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.EqualNode candidateElementActive[candidateIndex] = 0 unmatchedElementActive[unmatchedIndex] = 0 break } } }
// Match by exact id for (let i = 0; i < unmatchedElementIndices.length; i++) { const unmatchedIndex = unmatchedElementIndices[i]! if (!unmatchedElementActive[unmatchedIndex]) continue
const element = toChildNodes[unmatchedIndex] as Element const id = element.id
if (id === "") continue
const candidateBucket = candidateElementIndicesById.get(id) if (candidateBucket === undefined) continue
if (Array.isArray(candidateBucket)) { for (let c = 0; c < candidateBucket.length; c++) { const candidateIndex = candidateBucket[c]! if (!candidateElementWithIdActive[candidateIndex]) continue
if ( localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && namespaceURIMap[unmatchedIndex] === candidateNamespaceURIMap[candidateIndex] ) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.SameElement candidateElementWithIdActive[candidateIndex] = 0 unmatchedElementActive[unmatchedIndex] = 0 break } } } else { const candidateIndex = candidateBucket if (!candidateElementWithIdActive[candidateIndex]) continue
if ( localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && namespaceURIMap[unmatchedIndex] === candidateNamespaceURIMap[candidateIndex] ) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.SameElement candidateElementWithIdActive[candidateIndex] = 0 unmatchedElementActive[unmatchedIndex] = 0 } } }
// Match by idArray (to) against idSet (from) // Elements with idSets may not have IDs themselves, so we check candidateElements for (let i = 0; i < unmatchedElementIndices.length; i++) { const unmatchedIndex = unmatchedElementIndices[i]! if (!unmatchedElementActive[unmatchedIndex]) continue
const element = toChildNodes[unmatchedIndex] as Element const idArray = this.#idArrayMap.get(element)
if (!idArray) continue
candidateLoop: for (let c = 0; c < candidateElementIndices.length; c++) { const candidateIndex = candidateElementIndices[c]! if (!candidateElementActive[candidateIndex]) continue
const candidate = fromChildNodes[candidateIndex] as Element
if ( localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && namespaceURIMap[unmatchedIndex] === candidateNamespaceURIMap[candidateIndex] ) { const candidateIdSet = this.#idSetMap.get(candidate) if (candidateIdSet) { for (let a = 0; a < idArray.length; a++) { const arrayId = idArray[a]! if (candidateIdSet.has(arrayId)) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.SameElement candidateElementActive[candidateIndex] = 0 unmatchedElementActive[unmatchedIndex] = 0 break candidateLoop } } } } } }
// Match by heuristics for (let i = 0; i < unmatchedElementIndices.length; i++) { const unmatchedIndex = unmatchedElementIndices[i]! if (!unmatchedElementActive[unmatchedIndex]) continue
const element = toChildNodes[unmatchedIndex] as Element
const name = element.getAttribute("name") const href = element.getAttribute("href") const src = element.getAttribute("src")
for (let c = 0; c < candidateElementIndices.length; c++) { const candidateIndex = candidateElementIndices[c]! if (!candidateElementActive[candidateIndex]) continue const candidate = fromChildNodes[candidateIndex] as Element
if ( localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && namespaceURIMap[unmatchedIndex] === candidateNamespaceURIMap[candidateIndex] && ((name && name === candidate.getAttribute("name")) || (href && href === candidate.getAttribute("href")) || (src && src === candidate.getAttribute("src"))) ) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.SameElement candidateElementActive[candidateIndex] = 0 unmatchedElementActive[unmatchedIndex] = 0 break } } }
// Match by tagName (only for elements without distinguishing attributes) for (let i = 0; i < unmatchedElementIndices.length; i++) { const unmatchedIndex = unmatchedElementIndices[i]! if (!unmatchedElementActive[unmatchedIndex]) continue
const element = toChildNodes[unmatchedIndex] as Element
if (!canSoftMatchByTagName(element, this.#idArrayMap.has(element))) continue
const localName = localNameMap[unmatchedIndex]
for (let c = 0; c < candidateElementIndices.length; c++) { const candidateIndex = candidateElementIndices[c]! if (!candidateElementActive[candidateIndex]) continue
const candidate = fromChildNodes[candidateIndex] as Element
if (!canSoftMatchByTagName(candidate, this.#idSetMap.has(candidate))) continue
const candidateLocalName = candidateLocalNameMap[candidateIndex]
if (localName === candidateLocalName && namespaceURIMap[unmatchedIndex] === candidateNamespaceURIMap[candidateIndex]) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.SameElement candidateElementActive[candidateIndex] = 0 unmatchedElementActive[unmatchedIndex] = 0 break } } }
// Match nodes by isEqualNode (skip whitespace-only text nodes) for (let i = 0; i < unmatchedNodeIndices.length; i++) { const unmatchedIndex = unmatchedNodeIndices[i]!
const node = toChildNodes[unmatchedIndex]! for (let c = 0; c < candidateNodeIndices.length; c++) { const candidateIndex = candidateNodeIndices[c]! if (!candidateNodeActive[candidateIndex]) continue
const candidate = fromChildNodes[candidateIndex]! if (candidate.isEqualNode(node)) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.EqualNode candidateNodeActive[candidateIndex] = 0 unmatchedNodeActive[unmatchedIndex] = 0 break } } }
// Match by nodeType for (let i = 0; i < unmatchedNodeIndices.length; i++) { const unmatchedIndex = unmatchedNodeIndices[i]! if (!unmatchedNodeActive[unmatchedIndex]) continue
const nodeType = nodeTypeMap[unmatchedIndex]
for (let c = 0; c < candidateNodeIndices.length; c++) { const candidateIndex = candidateNodeIndices[c]! if (!candidateNodeActive[candidateIndex]) continue
if (nodeType === candidateNodeTypeMap[candidateIndex]) { matches[unmatchedIndex] = candidateIndex op[unmatchedIndex] = Operation.SameNode candidateNodeActive[candidateIndex] = 0 unmatchedNodeActive[unmatchedIndex] = 0 break } } }
// Remove any unmatched candidates first, before calculating LIS and repositioning for (let i = 0; i < candidateNodeIndices.length; i++) { const candidateIndex = candidateNodeIndices[i]! if (candidateNodeActive[candidateIndex]) this.#removeNode(fromChildNodes[candidateIndex]!) }
for (let i = 0; i < whitespaceNodeIndices.length; i++) { this.#removeNode(fromChildNodes[whitespaceNodeIndices[i]!]!) }
for (let i = 0; i < candidateElementIndices.length; i++) { const candidateIndex = candidateElementIndices[i]! if (candidateElementActive[candidateIndex]) this.#removeNode(fromChildNodes[candidateIndex]!) }
for (let i = 0; i < candidateElementWithIdIndices.length; i++) { const candidateIndex = candidateElementWithIdIndices[i]! if (candidateElementWithIdActive[candidateIndex]) this.#removeNode(fromChildNodes[candidateIndex]!) }
// Find LIS - these nodes don't need to move // matches already contains the fromChildNodes indices, so we can use it directly const lisIndices = longestIncreasingSubsequence(matches)
const shouldNotMove: Array<boolean> = new Array(fromChildNodes.length) for (let i = 0; i < lisIndices.length; i++) { shouldNotMove[matches[lisIndices[i]!]!] = true }
let insertionPoint: ChildNode | null = parent.firstChild for (let i = 0; i < toChildNodes.length; i++) { const node = toChildNodes[i]! const matchInd = matches[i] if (matchInd !== undefined) { const match = fromChildNodes[matchInd]! const operation = op[i]!
if (!shouldNotMove[matchInd]) { moveBefore(parent, match, insertionPoint) }
if (operation === Operation.EqualNode) { } else if (operation === Operation.SameElement) { // this.#morphMatchingElements(match as Element, node as Element) this.#morphMatchingElements(match as Element, node as Element) } else { this.#morphOneToOne(match, node) }
insertionPoint = match.nextSibling } else { if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) { parent.insertBefore(node, insertionPoint) this.#options.afterNodeAdded?.(node) insertionPoint = node.nextSibling } } }
this.#options.afterChildrenVisited?.(from) }
#replaceNode(node: ChildNode, newNode: ChildNode): void { const parent = node.parentNode
if (!parent) { this.#options.beforeNodeAdded?.(document, newNode, node) return }
const insertionPoint = node // Check if both removal and addition are allowed before starting the replacement if ( (this.#options.beforeNodeRemoved?.(node) ?? true) && (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) ) { parent.insertBefore(newNode, insertionPoint) this.#options.afterNodeAdded?.(newNode) node.remove() this.#options.afterNodeRemoved?.(node) } }
#removeNode(node: ChildNode): void { if (this.#options.beforeNodeRemoved?.(node) ?? true) { node.remove() this.#options.afterNodeRemoved?.(node) } }
#mapIdArraysForEach(nodeList: NodeList): void { for (const childNode of nodeList) { if (isParentNode(childNode)) { this.#mapIdArrays(childNode) } } }
// For each node with an ID, push that ID into the IdArray on the IdArrayMap, for each of its parent elements. #mapIdArrays(node: ParentNode): void { const idArrayMap = this.#idArrayMap
forEachDescendantElementWithId(node, (element) => { const id = element.id
let currentElement: Element | null = element
while (currentElement) { const idArray = idArrayMap.get(currentElement) if (idArray) { idArray.push(id) } else { idArrayMap.set(currentElement, [id]) } if (currentElement === node) break currentElement = currentElement.parentElement } }) }
// For each node with an ID, add that ID into the IdSet on the IdSetMap, for each of its parent elements. #mapIdSets(node: ParentNode): void { const idSetMap = this.#idSetMap
forEachDescendantElementWithId(node, (element) => { const id = element.id
let currentElement: Element | null = element
while (currentElement) { const idSet = idSetMap.get(currentElement) if (idSet) { idSet.add(id) } else { idSetMap.set(currentElement, new Set([id])) } if (currentElement === node) break currentElement = currentElement.parentElement } }) }}
function forEachDescendantElementWithId(node: ParentNode, callback: (element: Element) => void): void { const root = node as Node const ownerDocument = root.ownerDocument!
const walker = ownerDocument.createTreeWalker(root, TREE_WALKER_SHOW_ELEMENT) let current = walker.nextNode()
while (current) { const element = current as Element if (element.id !== "") callback(element) current = walker.nextNode() }}
function nodeListToArray(nodeList: NodeListOf<ChildNode>): Array<ChildNode>function nodeListToArray(nodeList: NodeList): Array<ChildNode>function nodeListToArray(nodeList: NodeList): Array<ChildNode> { const length = nodeList.length const array = new Array<ChildNode>(length) for (let i = 0; i < length; i++) { array[i] = nodeList[i] as ChildNode } return array}
function isWhitespaceTextNode(node: Node): boolean { if (node.nodeType !== TEXT_NODE_TYPE) return false
const value = node.nodeValue if (!value) return true
for (let i = 0; i < value.length; i++) { const code = value.charCodeAt(i) if (code === 32 || code === 9 || code === 10 || code === 13 || code === 12) continue if (code <= 127) return false return value.trim() === "" }
return true}
function trimFragmentEdgeWhitespace(fragment: DocumentFragment): void { let hasElementChild = false
for (let current = fragment.firstChild; current; current = current.nextSibling) { if (current.nodeType === ELEMENT_NODE_TYPE) { hasElementChild = true break } }
if (!hasElementChild) return
while (fragment.firstChild && isWhitespaceTextNode(fragment.firstChild)) { fragment.firstChild.remove() }
while (fragment.lastChild && isWhitespaceTextNode(fragment.lastChild)) { fragment.lastChild.remove() }}
function isInputElement(element: Element): element is HTMLInputElement { return element.localName === "input"}
function canMorphElementInPlace(from: Element, to: Element): boolean { if (from.localName !== to.localName) return false if (from.namespaceURI !== to.namespaceURI) return false if (isFormControl(from) && isFormControl(to)) { const fromId = from.id const toId = to.id
if ((fromId !== "" || toId !== "") && fromId !== toId) { return false } }
if (isInputElement(from) && isInputElement(to)) { return from.type === to.type }
return true}
function canSoftMatchByTagName(element: Element, hasDescendantIdMarker: boolean): boolean { return !hasStableSoftMatchIdentity(element, hasDescendantIdMarker)}
function hasStableSoftMatchIdentity(element: Element, hasDescendantIdMarker: boolean): boolean { return element.id !== "" || isFormControl(element) || hasDescendantIdMarker || hasMatchKeyAttribute(element)}
function hasMatchKeyAttribute(element: Element): boolean { return element.hasAttribute("name") || element.hasAttribute("href") || element.hasAttribute("src")}
function isFormControl(element: Element): boolean { const localName = element.localName return ( localName === "input" || localName === "textarea" || localName === "select" || (localName.includes("-") && (element.constructor as unknown as Record<string, unknown>)["formAssociated"] === true) )}
function isOptionElement(element: Element): element is HTMLOptionElement { return element.localName === "option"}
function isParentNode(node: Node): node is ParentNode { return !!IS_PARENT_NODE_TYPE[node.nodeType]}
function isNodeList(value: ChildNode | NodeListOf<ChildNode>): value is NodeListOf<ChildNode> { return Object.prototype.toString.call(value) === "[object NodeList]"}
// Find longest increasing subsequence to minimize moves during reordering// Returns the indices in the sequence that form the LISfunction longestIncreasingSubsequence(sequence: Array<number | undefined>): Array<number> { const n = sequence.length if (n === 0) return []
const smallestEnding = new Array<number>(n) const indices = new Array<number>(n) const prev = new Int32Array(n) prev.fill(-1)
let lisLength = 0
for (let i = 0; i < n; i++) { const val = sequence[i] if (val === undefined) continue
let left = 0 let right = lisLength
while (left < right) { const mid = Math.floor((left + right) / 2) if (smallestEnding[mid]! < val) left = mid + 1 else right = mid }
prev[i] = left > 0 ? indices[left - 1]! : -1
smallestEnding[left] = val indices[left] = i if (left === lisLength) lisLength++ }
const result = new Array<number>(lisLength) let curr = indices[lisLength - 1]!
for (let i = lisLength - 1; i >= 0; i--) { result[i] = curr curr = prev[curr]! }
return result}