···5566// Build and minify with Bun
77await build({
88- entrypoints: ["./src/morphlex.ts"],
88+ entrypoints: ["./src/morphlex.ts", "./src/data-morph.ts"],
99 outdir: "./dist",
1010 minify: true,
1111 sourcemap: "external",
bun.lockb
This is a binary file and will not be displayed.
+367
src/data-morph.ts
···11+type HTMLOrSVG = HTMLElement | SVGElement | MathMLElement
22+33+export const isHTMLOrSVG = (el: Node): el is HTMLOrSVG =>
44+ el instanceof HTMLElement || el instanceof SVGElement || el instanceof MathMLElement
55+66+const ctxIdMap = new Map<Node, Set<string>>()
77+const ctxPersistentIds = new Set<string>()
88+const oldIdTagNameMap = new Map<string, string>()
99+const duplicateIds = new Set<string>()
1010+const ctxPantry = document.createElement("div")
1111+ctxPantry.hidden = true
1212+1313+const aliasedIgnoreMorph = "ignore-morph"
1414+const aliasedIgnoreMorphAttr = `[${aliasedIgnoreMorph}]`
1515+export const morph = (
1616+ oldElt: Element | ShadowRoot,
1717+ newContent: DocumentFragment | Element,
1818+ mode: "outer" | "inner" = "outer",
1919+): void => {
2020+ if (
2121+ (isHTMLOrSVG(oldElt) &&
2222+ isHTMLOrSVG(newContent) &&
2323+ oldElt.hasAttribute(aliasedIgnoreMorph) &&
2424+ newContent.hasAttribute(aliasedIgnoreMorph)) ||
2525+ oldElt.parentElement?.closest(aliasedIgnoreMorphAttr)
2626+ ) {
2727+ return
2828+ }
2929+3030+ const normalizedElt = document.createElement("div")
3131+ normalizedElt.append(newContent)
3232+ document.body.insertAdjacentElement("afterend", ctxPantry)
3333+3434+ // Computes the set of IDs that persist between the two contents excluding duplicates
3535+ const oldIdElements = oldElt.querySelectorAll("[id]")
3636+ for (const { id, tagName } of oldIdElements) {
3737+ if (oldIdTagNameMap.has(id)) {
3838+ duplicateIds.add(id)
3939+ } else {
4040+ oldIdTagNameMap.set(id, tagName)
4141+ }
4242+ }
4343+ if (oldElt instanceof Element && oldElt.id) {
4444+ if (oldIdTagNameMap.has(oldElt.id)) {
4545+ duplicateIds.add(oldElt.id)
4646+ } else {
4747+ oldIdTagNameMap.set(oldElt.id, oldElt.tagName)
4848+ }
4949+ }
5050+5151+ ctxPersistentIds.clear()
5252+ const newIdElements = normalizedElt.querySelectorAll("[id]")
5353+ for (const { id, tagName } of newIdElements) {
5454+ if (ctxPersistentIds.has(id)) {
5555+ duplicateIds.add(id)
5656+ } else if (oldIdTagNameMap.get(id) === tagName) {
5757+ ctxPersistentIds.add(id)
5858+ }
5959+ }
6060+6161+ for (const id of duplicateIds) {
6262+ ctxPersistentIds.delete(id)
6363+ }
6464+6565+ oldIdTagNameMap.clear()
6666+ duplicateIds.clear()
6767+ ctxIdMap.clear()
6868+6969+ const parent = mode === "outer" ? oldElt.parentElement! : oldElt
7070+ populateIdMapWithTree(parent, oldIdElements)
7171+ populateIdMapWithTree(normalizedElt, newIdElements)
7272+7373+ morphChildren(parent, normalizedElt, mode === "outer" ? oldElt : null, oldElt.nextSibling)
7474+7575+ ctxPantry.remove()
7676+}
7777+7878+// This is the core algorithm for matching up children.
7979+// The idea is to use ID sets to try to match up nodes as faithfully as possible.
8080+// We greedily match, which allows us to keep the algorithm fast,
8181+// but by using ID sets, we are able to better match up with content deeper in the DOM.
8282+const morphChildren = (
8383+ oldParent: Element | ShadowRoot, // the old content that we are merging the new content into
8484+ newParent: Element, // the parent element of the new content
8585+ insertionPoint: Node | null = null, // the point in the DOM we start morphing at (defaults to first child)
8686+ endPoint: Node | null = null, // the point in the DOM we stop morphing at (defaults to after last child)
8787+): void => {
8888+ // normalize
8989+ if (oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement) {
9090+ // we can pretend the DocumentElement is an Element
9191+ oldParent = oldParent.content as unknown as Element
9292+ newParent = newParent.content as unknown as Element
9393+ }
9494+ insertionPoint ??= oldParent.firstChild
9595+9696+ // run through all the new content
9797+ for (const newChild of newParent.childNodes) {
9898+ // once we reach the end of the old parent content skip to the end and insert the rest
9999+ if (insertionPoint && insertionPoint !== endPoint) {
100100+ const bestMatch = findBestMatch(newChild, insertionPoint, endPoint)
101101+ if (bestMatch) {
102102+ // if the node to morph is not at the insertion point then remove/move up to it
103103+ if (bestMatch !== insertionPoint) {
104104+ let cursor: Node | null = insertionPoint
105105+ // Remove nodes between the start and end nodes
106106+ while (cursor && cursor !== bestMatch) {
107107+ const tempNode = cursor
108108+ cursor = cursor.nextSibling
109109+ removeNode(tempNode)
110110+ }
111111+ }
112112+ morphNode(bestMatch, newChild)
113113+ insertionPoint = bestMatch.nextSibling
114114+ continue
115115+ }
116116+ }
117117+118118+ // if the matching node is elsewhere in the original content
119119+ if (newChild instanceof Element && ctxPersistentIds.has(newChild.id)) {
120120+ // move it and all its children here and morph, will always be found
121121+ // Search for an element by ID within the document and pantry, and move it using moveBefore.
122122+ const movedChild = document.getElementById(newChild.id) as Element
123123+124124+ // Removes an element from its ancestors' ID maps.
125125+ // This is needed when an element is moved from the "future" via `moveBeforeId`.
126126+ // Otherwise, its erstwhile ancestors could be mistakenly moved to the pantry rather than being deleted,
127127+ // preventing their removal hooks from being called.
128128+ let current = movedChild
129129+ while ((current = current.parentNode as Element)) {
130130+ const idSet = ctxIdMap.get(current)
131131+ if (idSet) {
132132+ idSet.delete(newChild.id)
133133+ if (!idSet.size) {
134134+ ctxIdMap.delete(current)
135135+ }
136136+ }
137137+ }
138138+139139+ moveBefore(oldParent, movedChild, insertionPoint)
140140+ morphNode(movedChild, newChild)
141141+ insertionPoint = movedChild.nextSibling
142142+ continue
143143+ }
144144+145145+ // This performs the action of inserting a new node while handling situations where the node contains
146146+ // elements with persistent IDs and possible state info we can still preserve by moving in and then morphing
147147+ if (ctxIdMap.has(newChild)) {
148148+ // node has children with IDs with possible state so create a dummy elt of same type and apply full morph algorithm
149149+ const newEmptyChild = document.createElement((newChild as Element).tagName)
150150+ oldParent.insertBefore(newEmptyChild, insertionPoint)
151151+ morphNode(newEmptyChild, newChild)
152152+ insertionPoint = newEmptyChild.nextSibling
153153+ } else {
154154+ // optimization: no id state to preserve so we can just insert a clone of the newChild and its descendants
155155+ const newClonedChild = document.importNode(newChild, true) // importNode to not mutate newParent
156156+ oldParent.insertBefore(newClonedChild, insertionPoint)
157157+ insertionPoint = newClonedChild.nextSibling
158158+ }
159159+ }
160160+161161+ // remove any remaining old nodes that didn't match up with new content
162162+ while (insertionPoint && insertionPoint !== endPoint) {
163163+ const tempNode = insertionPoint
164164+ insertionPoint = insertionPoint.nextSibling
165165+ removeNode(tempNode)
166166+ }
167167+}
168168+169169+// Scans forward from the startPoint to the endPoint looking for a match for the node.
170170+// It looks for an id set match first, then a soft match.
171171+// We abort soft matching if we find two future soft matches, to reduce churn.
172172+const findBestMatch = (node: Node, startPoint: Node | null, endPoint: Node | null): Node | null => {
173173+ let bestMatch: Node | null | undefined = null
174174+ let nextSibling = node.nextSibling
175175+ let siblingSoftMatchCount = 0
176176+ let displaceMatchCount = 0
177177+178178+ // Max ID matches we are willing to displace in our search
179179+ const nodeMatchCount = ctxIdMap.get(node)?.size || 0
180180+181181+ let cursor = startPoint
182182+ while (cursor && cursor !== endPoint) {
183183+ // soft matching is a prerequisite for id set matching
184184+ if (isSoftMatch(cursor, node)) {
185185+ let isIdSetMatch = false
186186+ const oldSet = ctxIdMap.get(cursor)
187187+ const newSet = ctxIdMap.get(node)
188188+189189+ if (newSet && oldSet) {
190190+ for (const id of oldSet) {
191191+ // a potential match is an id in the new and old nodes that
192192+ // has not already been merged into the DOM
193193+ // But the newNode content we call this on has not been
194194+ // merged yet and we don't allow duplicate IDs so it is simple
195195+ if (newSet.has(id)) {
196196+ isIdSetMatch = true
197197+ break
198198+ }
199199+ }
200200+ }
201201+202202+ if (isIdSetMatch) {
203203+ return cursor // found an id set match, we're done!
204204+ }
205205+206206+ // we haven’t yet saved a soft match fallback
207207+ // the current soft match will hard match something else in the future, leave it
208208+ if (!bestMatch && !ctxIdMap.has(cursor)) {
209209+ // optimization: if node can't id set match, we can just return the soft match immediately
210210+ if (!nodeMatchCount) {
211211+ return cursor
212212+ }
213213+ // save this as the fallback if we get through the loop without finding a hard match
214214+ bestMatch = cursor
215215+ }
216216+ }
217217+218218+ // check for IDs we may be displaced when matching
219219+ displaceMatchCount += ctxIdMap.get(cursor)?.size || 0
220220+ if (displaceMatchCount > nodeMatchCount) {
221221+ // if we are going to displace more IDs than the node contains then
222222+ // we do not have a good candidate for an ID match, so return
223223+ break
224224+ }
225225+226226+ if (bestMatch === null && nextSibling && isSoftMatch(cursor, nextSibling)) {
227227+ // The next new node has a soft match with this node, so
228228+ // increment the count of future soft matches
229229+ siblingSoftMatchCount++
230230+ nextSibling = nextSibling.nextSibling
231231+232232+ // If there are two future soft matches, block soft matching for this node to allow
233233+ // future siblings to soft match. This is to reduce churn in the DOM when an element
234234+ // is prepended.
235235+ if (siblingSoftMatchCount >= 2) {
236236+ bestMatch = undefined
237237+ }
238238+ }
239239+240240+ cursor = cursor.nextSibling
241241+ }
242242+243243+ return bestMatch || null
244244+}
245245+246246+// ok to cast: if one is not element, `id` and `tagName` will be null and we'll just compare that.
247247+const isSoftMatch = (oldNode: Node, newNode: Node): boolean =>
248248+ oldNode.nodeType === newNode.nodeType &&
249249+ (oldNode as Element).tagName === (newNode as Element).tagName &&
250250+ // If oldElt has an `id` with possible state and it doesn’t match newElt.id then avoid morphing.
251251+ // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
252252+ // its not persistent, and new nodes can't have any hidden state.
253253+ (!(oldNode as Element).id || (oldNode as Element).id === (newNode as Element).id)
254254+255255+// Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
256256+// - Persistent nodes will be moved to the pantry for later reuse
257257+// - Other nodes will have their hooks called, and then are removed
258258+const removeNode = (node: Node): void => {
259259+ // are we going to id set match this later?
260260+ ctxIdMap.has(node)
261261+ ? // skip callbacks and move to pantry
262262+ moveBefore(ctxPantry, node, null)
263263+ : // remove for realsies
264264+ node.parentNode?.removeChild(node)
265265+}
266266+267267+// Moves an element before another element within the same parent.
268268+// Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
269269+// This is essentially a forward-compat wrapper.
270270+const moveBefore: (parentNode: Node, node: Node, after: Node | null) => void =
271271+ // @ts-expect-error
272272+ removeNode.call.bind(ctxPantry.moveBefore ?? ctxPantry.insertBefore)
273273+274274+const aliasedPreserveAttr = "preserve-attr"
275275+276276+// syncs the oldNode to the newNode, copying over all attributes and
277277+// inner element state from the newNode to the oldNode
278278+const morphNode = (
279279+ oldNode: Node, // root node to merge content into
280280+ newNode: Node, // new content to merge
281281+): Node => {
282282+ const type = newNode.nodeType
283283+284284+ // if is an element type, sync the attributes from the
285285+ // new node into the new node
286286+ if (type === 1 /* element type */) {
287287+ const oldElt = oldNode as Element
288288+ const newElt = newNode as Element
289289+ if (oldElt.hasAttribute(aliasedIgnoreMorph) && newElt.hasAttribute(aliasedIgnoreMorph)) {
290290+ return oldNode
291291+ }
292292+293293+ // many bothans died to bring us this information:
294294+ // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
295295+ // https://github.com/choojs/nanomorph/blob/master/lib/morph.js#L113
296296+ if (oldElt instanceof HTMLInputElement && newElt instanceof HTMLInputElement && newElt.type !== "file") {
297297+ // https://github.com/bigskysoftware/idiomorph/issues/27
298298+ // | old input value | new input value | behaviour |
299299+ // | --------------- | ---------------- | -------------------------------------- |
300300+ // | `null` | `null` | preserve old input value |
301301+ // | some value | the same value | preserve old input value |
302302+ // | some value | `null` | set old input value to `""` |
303303+ // | `null` | some value | set old input value to new input value |
304304+ // | some value | some other value | set old input value to new input value |
305305+ if (newElt.getAttribute("value") !== oldElt.getAttribute("value")) {
306306+ oldElt.value = newElt.getAttribute("value") ?? ""
307307+ }
308308+ } else if (oldElt instanceof HTMLTextAreaElement && newElt instanceof HTMLTextAreaElement) {
309309+ if (newElt.value !== oldElt.value) {
310310+ oldElt.value = newElt.value
311311+ }
312312+ if (oldElt.firstChild && oldElt.firstChild.nodeValue !== newElt.value) {
313313+ oldElt.firstChild.nodeValue = newElt.value
314314+ }
315315+ }
316316+317317+ const preserveAttrs = ((newNode as HTMLElement).getAttribute(aliasedPreserveAttr) ?? "").split(" ")
318318+319319+ for (const { name, value } of newElt.attributes) {
320320+ if (oldElt.getAttribute(name) !== value && !preserveAttrs.includes(name)) {
321321+ oldElt.setAttribute(name, value)
322322+ }
323323+ }
324324+325325+ for (let i = oldElt.attributes.length - 1; i >= 0; i--) {
326326+ const { name } = oldElt.attributes[i]!
327327+ if (!newElt.hasAttribute(name) && !preserveAttrs.includes(name)) {
328328+ oldElt.removeAttribute(name)
329329+ }
330330+ }
331331+332332+ if (!oldElt.isEqualNode(newElt)) {
333333+ morphChildren(oldElt, newElt)
334334+ }
335335+ }
336336+337337+ if (type === 8 /* comment */ || type === 3 /* text */) {
338338+ if (oldNode.nodeValue !== newNode.nodeValue) {
339339+ oldNode.nodeValue = newNode.nodeValue
340340+ }
341341+ }
342342+343343+ return oldNode
344344+}
345345+346346+// A bottom-up algorithm that populates a map of Element -> IdSet.
347347+// The ID set for a given element is the set of all IDs contained within its subtree.
348348+// As an optimization, we filter these IDs through the given list of persistent IDs,
349349+// because we don't need to bother considering IDed elements that won't be in the new content.
350350+const populateIdMapWithTree = (root: Element | ShadowRoot | null, elements: Iterable<Element>): void => {
351351+ for (const elt of elements) {
352352+ if (ctxPersistentIds.has(elt.id)) {
353353+ let current: Element | null = elt
354354+ // walk up the parent hierarchy of that element, adding the ID of element to the parent's ID set
355355+ while (current && current !== root) {
356356+ let idSet = ctxIdMap.get(current)
357357+ // if the ID set doesn’t exist, create it and insert it in the map
358358+ if (!idSet) {
359359+ idSet = new Set()
360360+ ctxIdMap.set(current, idSet)
361361+ }
362362+ idSet.add(elt.id)
363363+ current = current.parentElement
364364+ }
365365+ }
366366+ }
367367+}
+138-162
src/morphlex.ts
···11-const PARENT_NODE_TYPES = new Set([1, 9, 11])
21const SUPPORTS_MOVE_BEFORE = "moveBefore" in Element.prototype
22+const ELEMENT_NODE_TYPE = 1
33+const TEXT_NODE_TYPE = 3
44+const PARENT_NODE_TYPES = [false, true, false, false, false, false, false, false, false, true, false, true]
3544-type IdSet = Set<string>
55-type IdMap = WeakMap<Node, IdSet>
66+const candidateNodes: Set<number> = new Set()
77+const candidateElements: Set<number> = new Set()
88+const unmatchedNodes: Set<number> = new Set()
99+const unmatchedElements: Set<number> = new Set()
1010+const whitespaceNodes: Set<number> = new Set()
61177-declare const brand: unique symbol
88-type Branded<T, B extends string> = T & { [brand]: B }
99-1010-type PairOfNodes<N extends Node> = [N, N]
1111-type PairOfMatchingElements<E extends Element> = Branded<PairOfNodes<E>, "MatchingElementPair">
1212+type IdMap = WeakMap<Node, Array<string>>
12131314/**
1415 * Configuration options for morphing operations.
···148149 if (typeof to === "string") {
149150 const fragment = parseFragment(to)
150151151151- if (fragment.firstChild && fragment.childNodes.length === 1 && isElement(fragment.firstChild)) {
152152+ if (fragment.firstChild && fragment.childNodes.length === 1 && fragment.firstChild.nodeType === ELEMENT_NODE_TYPE) {
152153 to = fragment.firstChild
153154 } else {
154155 throw new Error("[Morphlex] The string was not a valid HTML element.")
155156 }
156157 }
157158158158- const pair: PairOfNodes<Node> = [from, to]
159159- if (isElementPair(pair) && isMatchingElementPair(pair)) {
159159+ if (
160160+ from.nodeType === ELEMENT_NODE_TYPE &&
161161+ to.nodeType === ELEMENT_NODE_TYPE &&
162162+ (from as Element).localName === (to as Element).localName
163163+ ) {
160164 if (isParentNode(from)) flagDirtyInputs(from)
161161- new Morph(options).visitChildNodes(pair)
165165+ new Morph(options).visitChildNodes(from as Element, to as Element)
162166 } else {
163167 throw new Error("[Morphlex] You can only do an inner morph with matching elements.")
164168 }
···166170167171function flagDirtyInputs(node: ParentNode): void {
168172 for (const input of node.querySelectorAll("input")) {
169169- if (!input.name) continue
170170-171171- if (input.value !== input.defaultValue) {
172172- input.setAttribute("morphlex-dirty", "")
173173- }
174174-175175- if (input.checked !== input.defaultChecked) {
173173+ if ((input.name && input.value !== input.defaultValue) || input.checked !== input.defaultChecked) {
176174 input.setAttribute("morphlex-dirty", "")
177175 }
178176 }
179177180178 for (const element of node.querySelectorAll("option")) {
181181- if (!element.value) continue
182182-183183- if (element.selected !== element.defaultSelected) {
179179+ if (element.value && element.selected !== element.defaultSelected) {
184180 element.setAttribute("morphlex-dirty", "")
185181 }
186182 }
···208204 if (node === insertionPoint) return
209205 if (node.parentNode === parent) {
210206 if (node.nextSibling === insertionPoint) return
211211- if (supportsMoveBefore(parent)) {
212212- parent.moveBefore(node, insertionPoint)
207207+ if (SUPPORTS_MOVE_BEFORE) {
208208+ ;(parent as NodeWithMoveBefore).moveBefore(node, insertionPoint)
213209 return
214210 }
215211 }
···227223228224 // Find longest increasing subsequence to minimize moves during reordering
229225 // Returns the indices in the sequence that form the LIS
230230- #longestIncreasingSubsequence(sequence: Array<number>): Array<number> {
226226+ #longestIncreasingSubsequence(sequence: Array<number | undefined>): Array<number> {
231227 const n = sequence.length
232228 if (n === 0) return []
233229···236232 // indices[i] = index in sequence where smallestEnding[i] occurs
237233 const indices: Array<number> = []
238234 // prev[i] = previous index in the LIS ending at sequence[i]
239239- const prev: Array<number> = Array.from({ length: n }, () => -1)
235235+ const prev: Array<number> = new Array(n)
240236241237 // Build the LIS by processing each value
242238 for (let i = 0; i < n; i++) {
243243- const val = sequence[i]!
244244- if (val === -1) continue // Skip new nodes (not in original sequence)
239239+ const val = sequence[i]
240240+ if (val === undefined) continue // Skip new nodes (not in original sequence)
245241246242 // Binary search: find where this value fits in smallestEnding
247243 let left = 0
···254250 }
255251256252 // Link this element to the previous one in the subsequence
257257- if (left > 0) prev[i] = indices[left - 1]!
253253+ prev[i] = left > 0 ? indices[left - 1]! : -1
258254259255 // Either extend the sequence or update an existing position
260256 if (left === smallestEnding.length) {
···304300 } else if (length === 1) {
305301 this.#morphOneToOne(from, to[0]!)
306302 } else if (length > 1) {
307307- const newNodes = Array.from(to)
303303+ const newNodes = [...to]
308304 this.#morphOneToOne(from, newNodes.shift()!)
309305 const insertionPoint = from.nextSibling
310306 const parent = from.parentNode || document
311307312312- for (const newNode of newNodes) {
308308+ for (let i = 0; i < newNodes.length; i++) {
309309+ const newNode = newNodes[i]!
313310 if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) {
314314- moveBefore(parent, newNode, insertionPoint)
311311+ parent.insertBefore(newNode, insertionPoint)
315312 this.#options.afterNodeAdded?.(newNode)
316313 }
317314 }
···325322326323 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return
327324328328- const pair: PairOfNodes<ChildNode> = [from, to]
329329-330330- if (isElementPair(pair)) {
331331- if (isMatchingElementPair(pair)) {
332332- this.#morphMatchingElements(pair)
325325+ if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) {
326326+ if ((from as Element).localName === (to as Element).localName) {
327327+ this.#morphMatchingElements(from as Element, to as Element)
333328 } else {
334334- this.#morphNonMatchingElements(pair)
329329+ this.#morphNonMatchingElements(from as Element, to as Element)
335330 }
336331 } else {
337337- this.#morphOtherNode(pair)
332332+ this.#morphOtherNode(from, to)
338333 }
339334340335 this.#options.afterNodeVisited?.(from, to)
341336 }
342337343343- #morphMatchingElements(pair: PairOfMatchingElements<Element>): void {
344344- const [from, to] = pair
345345-338338+ #morphMatchingElements(from: Element, to: Element): void {
346339 if (from.hasAttributes() || to.hasAttributes()) {
347347- this.#visitAttributes(pair)
340340+ this.#visitAttributes(from, to)
348341 }
349342350350- if (isTextAreaElement(from) && isTextAreaElement(to)) {
351351- this.#visitTextArea(pair as PairOfMatchingElements<HTMLTextAreaElement>)
343343+ if ("textarea" === from.localName && "textarea" === to.localName) {
344344+ this.#visitTextArea(from as HTMLTextAreaElement, to as HTMLTextAreaElement)
352345 } else if (from.hasChildNodes() || to.hasChildNodes()) {
353353- this.visitChildNodes(pair)
346346+ this.visitChildNodes(from, to)
354347 }
355348 }
356349357357- #morphNonMatchingElements([from, to]: PairOfNodes<Element>): void {
350350+ #morphNonMatchingElements(from: Element, to: Element): void {
358351 this.#replaceNode(from, to)
359352 }
360353361361- #morphOtherNode([from, to]: PairOfNodes<ChildNode>): void {
354354+ #morphOtherNode(from: ChildNode, to: ChildNode): void {
362355 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) {
363356 from.nodeValue = to.nodeValue
364357 } else {
···366359 }
367360 }
368361369369- #visitAttributes([from, to]: PairOfMatchingElements<Element>): void {
362362+ #visitAttributes(from: Element, to: Element): void {
370363 if (from.hasAttribute("morphlex-dirty")) {
371364 from.removeAttribute("morphlex-dirty")
372365 }
373366374367 // First pass: update/add attributes from reference (iterate forwards)
375375- for (const { name, value } of to.attributes) {
368368+ const toAttributes = to.attributes
369369+ for (let i = 0; i < toAttributes.length; i++) {
370370+ const { name, value } = toAttributes[i]!
376371 if (name === "value") {
377372 if (isInputElement(from) && from.value !== value) {
378373 if (!this.#options.preserveChanges || from.value === from.defaultValue) {
···436431 }
437432 }
438433439439- #visitTextArea([from, to]: PairOfMatchingElements<HTMLTextAreaElement>): void {
434434+ #visitTextArea(from: HTMLTextAreaElement, to: HTMLTextAreaElement): void {
440435 const newTextContent = to.textContent || ""
441436 const isModified = from.value !== from.defaultValue
442437···450445 from.value = from.defaultValue
451446 }
452447453453- visitChildNodes([from, to]: PairOfMatchingElements<Element>): void {
448448+ visitChildNodes(from: Element, to: Element): void {
454449 if (!(this.#options.beforeChildrenVisited?.(from) ?? true)) return
455450 const parent = from
456451457457- const fromChildNodes = Array.from(from.childNodes)
458458- const toChildNodes = Array.from(to.childNodes)
452452+ const fromChildNodes = [...from.childNodes]
453453+ const toChildNodes = [...to.childNodes]
459454460460- const candidateNodes: Set<number> = new Set()
461461- const candidateElements: Set<number> = new Set()
455455+ candidateNodes.clear()
456456+ candidateElements.clear()
457457+ unmatchedNodes.clear()
458458+ unmatchedElements.clear()
459459+ whitespaceNodes.clear()
462460463463- const unmatchedNodes: Set<number> = new Set()
464464- const unmatchedElements: Set<number> = new Set()
465465-466466- const matches: Array<ChildNode | null> = Array.from({ length: toChildNodes.length }, () => null)
461461+ const seq: Array<number | undefined> = []
462462+ const matches: Array<number | undefined> = []
467463468464 for (let i = 0; i < fromChildNodes.length; i++) {
469465 const candidate = fromChildNodes[i]!
470470- if (isElement(candidate)) candidateElements.add(i)
471471- else candidateNodes.add(i)
466466+ const nodeType = candidate.nodeType
467467+468468+ if (nodeType === ELEMENT_NODE_TYPE) {
469469+ candidateElements.add(i)
470470+ } else if (nodeType === TEXT_NODE_TYPE && candidate.textContent?.trim() === "") {
471471+ whitespaceNodes.add(i)
472472+ } else {
473473+ candidateNodes.add(i)
474474+ }
472475 }
473476474477 for (let i = 0; i < toChildNodes.length; i++) {
475478 const node = toChildNodes[i]!
476476- if (isElement(node)) unmatchedElements.add(i)
477477- else unmatchedNodes.add(i)
479479+ const nodeType = node.nodeType
480480+481481+ if (nodeType === ELEMENT_NODE_TYPE) {
482482+ unmatchedElements.add(i)
483483+ } else if (nodeType === TEXT_NODE_TYPE && node.textContent?.trim() === "") {
484484+ continue
485485+ } else {
486486+ unmatchedNodes.add(i)
487487+ }
478488 }
479489480490 // Match elements by isEqualNode
···482492 const element = toChildNodes[unmatchedIndex] as Element
483493484494 for (const candidateIndex of candidateElements) {
485485- const candidate = fromChildNodes[candidateIndex]!
495495+ const candidate = fromChildNodes[candidateIndex] as Element
496496+486497 if (candidate.isEqualNode(element)) {
487487- matches[unmatchedIndex] = candidate
498498+ matches[unmatchedIndex] = candidateIndex
499499+ seq[candidateIndex] = unmatchedIndex
488500 candidateElements.delete(candidateIndex)
489501 unmatchedElements.delete(unmatchedIndex)
490502 break
···492504 }
493505 }
494506495495- // Match by exact id
507507+ // Match by exact id or idSet
496508 for (const unmatchedIndex of unmatchedElements) {
497509 const element = toChildNodes[unmatchedIndex] as Element
498510499511 const id = element.id
500500- if (id === "") continue
512512+ const idSet = this.#idMap.get(element)
501513502502- for (const candidateIndex of candidateElements) {
514514+ if (id === "" && !idSet) continue
515515+516516+ candidateLoop: for (const candidateIndex of candidateElements) {
503517 const candidate = fromChildNodes[candidateIndex] as Element
504504- if (element.localName === candidate.localName && id === candidate.id) {
505505- matches[unmatchedIndex] = candidate
518518+519519+ // Match by exact id
520520+ if (id !== "" && element.localName === candidate.localName && id === candidate.id) {
521521+ matches[unmatchedIndex] = candidateIndex
522522+ seq[candidateIndex] = unmatchedIndex
506523 candidateElements.delete(candidateIndex)
507524 unmatchedElements.delete(unmatchedIndex)
508508- break
525525+ break candidateLoop
509526 }
510510- }
511511- }
512527513513- // Match by idSet
514514- for (const unmatchedIndex of unmatchedElements) {
515515- const element = toChildNodes[unmatchedIndex] as Element
516516-517517- const idSet = this.#idMap.get(element)
518518- if (!idSet) continue
519519-520520- candidateLoop: for (const candidateIndex of candidateElements) {
521521- const candidate = fromChildNodes[candidateIndex] as Element
522522- const candidateIdSet = this.#idMap.get(candidate)
523523- if (candidateIdSet) {
524524- for (const id of idSet) {
525525- if (candidateIdSet.has(id)) {
526526- matches[unmatchedIndex] = candidate
527527- candidateElements.delete(candidateIndex)
528528- unmatchedElements.delete(unmatchedIndex)
529529- break candidateLoop
528528+ // Match by idSet
529529+ if (idSet) {
530530+ const candidateIdSet = this.#idMap.get(candidate)
531531+ if (candidateIdSet) {
532532+ for (let i = 0; i < idSet.length; i++) {
533533+ const setId = idSet[i]!
534534+ for (let k = 0; k < candidateIdSet.length; k++) {
535535+ if (candidateIdSet[k] === setId) {
536536+ matches[unmatchedIndex] = candidateIndex
537537+ seq[candidateIndex] = unmatchedIndex
538538+ candidateElements.delete(candidateIndex)
539539+ unmatchedElements.delete(unmatchedIndex)
540540+ break candidateLoop
541541+ }
542542+ }
530543 }
531544 }
532545 }
···549562 (href && href === candidate.getAttribute("href")) ||
550563 (src && src === candidate.getAttribute("src")))
551564 ) {
552552- matches[unmatchedIndex] = candidate
565565+ matches[unmatchedIndex] = candidateIndex
566566+ seq[candidateIndex] = unmatchedIndex
553567 candidateElements.delete(candidateIndex)
554568 unmatchedElements.delete(unmatchedIndex)
555569 break
···570584 // Treat inputs with different type as though they are different tags.
571585 continue
572586 }
573573- matches[unmatchedIndex] = candidate
587587+ matches[unmatchedIndex] = candidateIndex
588588+ seq[candidateIndex] = unmatchedIndex
574589 candidateElements.delete(candidateIndex)
575590 unmatchedElements.delete(unmatchedIndex)
576591 break
···581596 // Match nodes by isEqualNode (skip whitespace-only text nodes)
582597 for (const unmatchedIndex of unmatchedNodes) {
583598 const node = toChildNodes[unmatchedIndex]!
584584- if (isWhitespace(node)) continue
585599586600 for (const candidateIndex of candidateNodes) {
587601 const candidate = fromChildNodes[candidateIndex]!
588602 if (candidate.isEqualNode(node)) {
589589- matches[unmatchedIndex] = candidate
603603+ matches[unmatchedIndex] = candidateIndex
604604+ seq[candidateIndex] = unmatchedIndex
590605 candidateNodes.delete(candidateIndex)
591606 unmatchedNodes.delete(unmatchedIndex)
592607 break
···594609 }
595610 }
596611597597- // Match by nodeType (skip whitespace-only text nodes)
612612+ // Match by nodeType
598613 for (const unmatchedIndex of unmatchedNodes) {
599614 const node = toChildNodes[unmatchedIndex]!
600600- if (isWhitespace(node)) continue
601615602616 const nodeType = node.nodeType
603617604618 for (const candidateIndex of candidateNodes) {
605619 const candidate = fromChildNodes[candidateIndex]!
606620 if (nodeType === candidate.nodeType) {
607607- matches[unmatchedIndex] = candidate
621621+ matches[unmatchedIndex] = candidateIndex
622622+ seq[candidateIndex] = unmatchedIndex
608623 candidateNodes.delete(candidateIndex)
609624 unmatchedNodes.delete(unmatchedIndex)
610625 break
···613628 }
614629615630 // Remove any unmatched candidates first, before calculating LIS and repositioning
616616- for (const candidateIndex of candidateNodes) {
617617- this.#removeNode(fromChildNodes[candidateIndex]!)
618618- }
619619-620620- for (const candidateIndex of candidateElements) {
621621- this.#removeNode(fromChildNodes[candidateIndex]!)
622622- }
623623-624624- // Build sequence of current indices for LIS calculation (after removals)
625625- const fromIndex = new Map<ChildNode, number>()
626626- Array.from(parent.childNodes).forEach((node, i) => fromIndex.set(node, i))
627627-628628- const sequence: Array<number> = []
629629- for (let i = 0; i < matches.length; i++) {
630630- const match = matches[i]
631631- if (match && fromIndex.has(match)) {
632632- sequence.push(fromIndex.get(match)!)
633633- } else {
634634- sequence.push(-1) // New node, not in sequence
635635- }
636636- }
631631+ for (const i of candidateNodes) this.#removeNode(fromChildNodes[i]!)
632632+ for (const i of whitespaceNodes) this.#removeNode(fromChildNodes[i]!)
633633+ for (const i of candidateElements) this.#removeNode(fromChildNodes[i]!)
637634638635 // Find LIS - these nodes don't need to move
639639- const lisIndices = this.#longestIncreasingSubsequence(sequence)
640640- const shouldNotMove = new Set<number>()
641641- for (const i of lisIndices) {
642642- shouldNotMove.add(sequence[i]!)
636636+ // matches already contains the fromChildNodes indices, so we can use it directly
637637+ const lisIndices = this.#longestIncreasingSubsequence(matches)
638638+639639+ const shouldNotMove: Array<boolean> = new Array(fromChildNodes.length)
640640+ for (let i = 0; i < lisIndices.length; i++) {
641641+ shouldNotMove[matches[lisIndices[i]!]!] = true
643642 }
644643645644 let insertionPoint: ChildNode | null = parent.firstChild
646645 for (let i = 0; i < toChildNodes.length; i++) {
647646 const node = toChildNodes[i]!
648648- const match = matches[i]
649649- if (match) {
650650- const matchIndex = fromIndex.get(match)!
651651- if (!shouldNotMove.has(matchIndex)) {
647647+ const matchInd = matches[i]
648648+ if (matchInd !== undefined) {
649649+ const match = fromChildNodes[matchInd]!
650650+651651+ if (!shouldNotMove[matchInd]) {
652652 moveBefore(parent, match, insertionPoint)
653653 }
654654 this.#morphOneToOne(match, node)
655655 insertionPoint = match.nextSibling
656656 } else {
657657 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) {
658658- moveBefore(parent, node, insertionPoint)
658658+ parent.insertBefore(node, insertionPoint)
659659 this.#options.afterNodeAdded?.(node)
660660 insertionPoint = node.nextSibling
661661 }
···673673 (this.#options.beforeNodeRemoved?.(node) ?? true) &&
674674 (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true)
675675 ) {
676676- moveBefore(parent, newNode, insertionPoint)
676676+ parent.insertBefore(newNode, insertionPoint)
677677 this.#options.afterNodeAdded?.(newNode)
678678 node.remove()
679679 this.#options.afterNodeRemoved?.(node)
···697697698698 // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements.
699699 #mapIdSets(node: ParentNode): void {
700700- for (const elementWithId of node.querySelectorAll("[id]")) {
701701- const id = elementWithId.id
700700+ const idMap = this.#idMap
701701+702702+ for (const element of node.querySelectorAll("[id]")) {
703703+ const id = element.id
702704703705 if (id === "") continue
704706705705- let currentElement: Element | null = elementWithId
707707+ let currentElement: Element | null = element
706708707709 while (currentElement) {
708708- const idSet: IdSet | undefined = this.#idMap.get(currentElement)
709709- if (idSet) idSet.add(id)
710710- else this.#idMap.set(currentElement, new Set([id]))
710710+ const idSet: Array<string> | undefined = idMap.get(currentElement)
711711+ if (idSet) idSet.push(id)
712712+ else idMap.set(currentElement, [id])
711713 if (currentElement === node) break
712714 currentElement = currentElement.parentElement
713715 }
···715717 }
716718}
717719718718-function supportsMoveBefore(_node: ParentNode): _node is NodeWithMoveBefore {
719719- return SUPPORTS_MOVE_BEFORE
720720-}
721721-722722-function isMatchingElementPair(pair: PairOfNodes<Element>): pair is PairOfMatchingElements<Element> {
723723- const [a, b] = pair
724724- return a.localName === b.localName
725725-}
726726-727727-function isElementPair(pair: PairOfNodes<Node>): pair is PairOfNodes<Element> {
728728- const [a, b] = pair
729729- return isElement(a) && isElement(b)
730730-}
731731-732732-function isElement(node: Node): node is Element {
733733- return node.nodeType === 1
734734-}
735735-736720function isInputElement(element: Element): element is HTMLInputElement {
737721 return element.localName === "input"
738722}
739723740740-function isWhitespace(node: ChildNode): boolean {
741741- return node.nodeType === 3 && node.textContent?.trim() === ""
742742-}
743743-744724function isOptionElement(element: Element): element is HTMLOptionElement {
745725 return element.localName === "option"
746726}
747727748748-function isTextAreaElement(element: Element): element is HTMLTextAreaElement {
749749- return element.localName === "textarea"
750750-}
751751-752728function isParentNode(node: Node): node is ParentNode {
753753- return PARENT_NODE_TYPES.has(node.nodeType)
729729+ return !!PARENT_NODE_TYPES[node.nodeType]
754730}