Precise DOM morphing
morphing typescript dom
at d50eee5abe2577bdbf85e3187cca5656cf4e2a23 367 lines 14 kB view raw
1type HTMLOrSVG = HTMLElement | SVGElement | MathMLElement 2 3export const isHTMLOrSVG = (el: Node): el is HTMLOrSVG => 4 el instanceof HTMLElement || el instanceof SVGElement || el instanceof MathMLElement 5 6const ctxIdMap = new Map<Node, Set<string>>() 7const ctxPersistentIds = new Set<string>() 8const oldIdTagNameMap = new Map<string, string>() 9const duplicateIds = new Set<string>() 10const ctxPantry = document.createElement("div") 11ctxPantry.hidden = true 12 13const aliasedIgnoreMorph = "ignore-morph" 14const aliasedIgnoreMorphAttr = `[${aliasedIgnoreMorph}]` 15export const morph = ( 16 oldElt: Element | ShadowRoot, 17 newContent: DocumentFragment | Element, 18 mode: "outer" | "inner" = "outer", 19): void => { 20 if ( 21 (isHTMLOrSVG(oldElt) && 22 isHTMLOrSVG(newContent) && 23 oldElt.hasAttribute(aliasedIgnoreMorph) && 24 newContent.hasAttribute(aliasedIgnoreMorph)) || 25 oldElt.parentElement?.closest(aliasedIgnoreMorphAttr) 26 ) { 27 return 28 } 29 30 const normalizedElt = document.createElement("div") 31 normalizedElt.append(newContent) 32 document.body.insertAdjacentElement("afterend", ctxPantry) 33 34 // Computes the set of IDs that persist between the two contents excluding duplicates 35 const oldIdElements = oldElt.querySelectorAll("[id]") 36 for (const { id, tagName } of oldIdElements) { 37 if (oldIdTagNameMap.has(id)) { 38 duplicateIds.add(id) 39 } else { 40 oldIdTagNameMap.set(id, tagName) 41 } 42 } 43 if (oldElt instanceof Element && oldElt.id) { 44 if (oldIdTagNameMap.has(oldElt.id)) { 45 duplicateIds.add(oldElt.id) 46 } else { 47 oldIdTagNameMap.set(oldElt.id, oldElt.tagName) 48 } 49 } 50 51 ctxPersistentIds.clear() 52 const newIdElements = normalizedElt.querySelectorAll("[id]") 53 for (const { id, tagName } of newIdElements) { 54 if (ctxPersistentIds.has(id)) { 55 duplicateIds.add(id) 56 } else if (oldIdTagNameMap.get(id) === tagName) { 57 ctxPersistentIds.add(id) 58 } 59 } 60 61 for (const id of duplicateIds) { 62 ctxPersistentIds.delete(id) 63 } 64 65 oldIdTagNameMap.clear() 66 duplicateIds.clear() 67 ctxIdMap.clear() 68 69 const parent = mode === "outer" ? oldElt.parentElement! : oldElt 70 populateIdMapWithTree(parent, oldIdElements) 71 populateIdMapWithTree(normalizedElt, newIdElements) 72 73 morphChildren(parent, normalizedElt, mode === "outer" ? oldElt : null, oldElt.nextSibling) 74 75 ctxPantry.remove() 76} 77 78// This is the core algorithm for matching up children. 79// The idea is to use ID sets to try to match up nodes as faithfully as possible. 80// We greedily match, which allows us to keep the algorithm fast, 81// but by using ID sets, we are able to better match up with content deeper in the DOM. 82const morphChildren = ( 83 oldParent: Element | ShadowRoot, // the old content that we are merging the new content into 84 newParent: Element, // the parent element of the new content 85 insertionPoint: Node | null = null, // the point in the DOM we start morphing at (defaults to first child) 86 endPoint: Node | null = null, // the point in the DOM we stop morphing at (defaults to after last child) 87): void => { 88 // normalize 89 if (oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement) { 90 // we can pretend the DocumentElement is an Element 91 oldParent = oldParent.content as unknown as Element 92 newParent = newParent.content as unknown as Element 93 } 94 insertionPoint ??= oldParent.firstChild 95 96 // run through all the new content 97 for (const newChild of newParent.childNodes) { 98 // once we reach the end of the old parent content skip to the end and insert the rest 99 if (insertionPoint && insertionPoint !== endPoint) { 100 const bestMatch = findBestMatch(newChild, insertionPoint, endPoint) 101 if (bestMatch) { 102 // if the node to morph is not at the insertion point then remove/move up to it 103 if (bestMatch !== insertionPoint) { 104 let cursor: Node | null = insertionPoint 105 // Remove nodes between the start and end nodes 106 while (cursor && cursor !== bestMatch) { 107 const tempNode = cursor 108 cursor = cursor.nextSibling 109 removeNode(tempNode) 110 } 111 } 112 morphNode(bestMatch, newChild) 113 insertionPoint = bestMatch.nextSibling 114 continue 115 } 116 } 117 118 // if the matching node is elsewhere in the original content 119 if (newChild instanceof Element && ctxPersistentIds.has(newChild.id)) { 120 // move it and all its children here and morph, will always be found 121 // Search for an element by ID within the document and pantry, and move it using moveBefore. 122 const movedChild = document.getElementById(newChild.id) as Element 123 124 // Removes an element from its ancestors' ID maps. 125 // This is needed when an element is moved from the "future" via `moveBeforeId`. 126 // Otherwise, its erstwhile ancestors could be mistakenly moved to the pantry rather than being deleted, 127 // preventing their removal hooks from being called. 128 let current = movedChild 129 while ((current = current.parentNode as Element)) { 130 const idSet = ctxIdMap.get(current) 131 if (idSet) { 132 idSet.delete(newChild.id) 133 if (!idSet.size) { 134 ctxIdMap.delete(current) 135 } 136 } 137 } 138 139 moveBefore(oldParent, movedChild, insertionPoint) 140 morphNode(movedChild, newChild) 141 insertionPoint = movedChild.nextSibling 142 continue 143 } 144 145 // This performs the action of inserting a new node while handling situations where the node contains 146 // elements with persistent IDs and possible state info we can still preserve by moving in and then morphing 147 if (ctxIdMap.has(newChild)) { 148 // node has children with IDs with possible state so create a dummy elt of same type and apply full morph algorithm 149 const newEmptyChild = document.createElement((newChild as Element).tagName) 150 oldParent.insertBefore(newEmptyChild, insertionPoint) 151 morphNode(newEmptyChild, newChild) 152 insertionPoint = newEmptyChild.nextSibling 153 } else { 154 // optimization: no id state to preserve so we can just insert a clone of the newChild and its descendants 155 const newClonedChild = document.importNode(newChild, true) // importNode to not mutate newParent 156 oldParent.insertBefore(newClonedChild, insertionPoint) 157 insertionPoint = newClonedChild.nextSibling 158 } 159 } 160 161 // remove any remaining old nodes that didn't match up with new content 162 while (insertionPoint && insertionPoint !== endPoint) { 163 const tempNode = insertionPoint 164 insertionPoint = insertionPoint.nextSibling 165 removeNode(tempNode) 166 } 167} 168 169// Scans forward from the startPoint to the endPoint looking for a match for the node. 170// It looks for an id set match first, then a soft match. 171// We abort soft matching if we find two future soft matches, to reduce churn. 172const findBestMatch = (node: Node, startPoint: Node | null, endPoint: Node | null): Node | null => { 173 let bestMatch: Node | null | undefined = null 174 let nextSibling = node.nextSibling 175 let siblingSoftMatchCount = 0 176 let displaceMatchCount = 0 177 178 // Max ID matches we are willing to displace in our search 179 const nodeMatchCount = ctxIdMap.get(node)?.size || 0 180 181 let cursor = startPoint 182 while (cursor && cursor !== endPoint) { 183 // soft matching is a prerequisite for id set matching 184 if (isSoftMatch(cursor, node)) { 185 let isIdSetMatch = false 186 const oldSet = ctxIdMap.get(cursor) 187 const newSet = ctxIdMap.get(node) 188 189 if (newSet && oldSet) { 190 for (const id of oldSet) { 191 // a potential match is an id in the new and old nodes that 192 // has not already been merged into the DOM 193 // But the newNode content we call this on has not been 194 // merged yet and we don't allow duplicate IDs so it is simple 195 if (newSet.has(id)) { 196 isIdSetMatch = true 197 break 198 } 199 } 200 } 201 202 if (isIdSetMatch) { 203 return cursor // found an id set match, we're done! 204 } 205 206 // we haven’t yet saved a soft match fallback 207 // the current soft match will hard match something else in the future, leave it 208 if (!bestMatch && !ctxIdMap.has(cursor)) { 209 // optimization: if node can't id set match, we can just return the soft match immediately 210 if (!nodeMatchCount) { 211 return cursor 212 } 213 // save this as the fallback if we get through the loop without finding a hard match 214 bestMatch = cursor 215 } 216 } 217 218 // check for IDs we may be displaced when matching 219 displaceMatchCount += ctxIdMap.get(cursor)?.size || 0 220 if (displaceMatchCount > nodeMatchCount) { 221 // if we are going to displace more IDs than the node contains then 222 // we do not have a good candidate for an ID match, so return 223 break 224 } 225 226 if (bestMatch === null && nextSibling && isSoftMatch(cursor, nextSibling)) { 227 // The next new node has a soft match with this node, so 228 // increment the count of future soft matches 229 siblingSoftMatchCount++ 230 nextSibling = nextSibling.nextSibling 231 232 // If there are two future soft matches, block soft matching for this node to allow 233 // future siblings to soft match. This is to reduce churn in the DOM when an element 234 // is prepended. 235 if (siblingSoftMatchCount >= 2) { 236 bestMatch = undefined 237 } 238 } 239 240 cursor = cursor.nextSibling 241 } 242 243 return bestMatch || null 244} 245 246// ok to cast: if one is not element, `id` and `tagName` will be null and we'll just compare that. 247const isSoftMatch = (oldNode: Node, newNode: Node): boolean => 248 oldNode.nodeType === newNode.nodeType && 249 (oldNode as Element).tagName === (newNode as Element).tagName && 250 // If oldElt has an `id` with possible state and it doesn’t match newElt.id then avoid morphing. 251 // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, 252 // its not persistent, and new nodes can't have any hidden state. 253 (!(oldNode as Element).id || (oldNode as Element).id === (newNode as Element).id) 254 255// Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: 256// - Persistent nodes will be moved to the pantry for later reuse 257// - Other nodes will have their hooks called, and then are removed 258const removeNode = (node: Node): void => { 259 // are we going to id set match this later? 260 ctxIdMap.has(node) 261 ? // skip callbacks and move to pantry 262 moveBefore(ctxPantry, node, null) 263 : // remove for realsies 264 node.parentNode?.removeChild(node) 265} 266 267// Moves an element before another element within the same parent. 268// Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. 269// This is essentially a forward-compat wrapper. 270const moveBefore: (parentNode: Node, node: Node, after: Node | null) => void = 271 // @ts-expect-error 272 removeNode.call.bind(ctxPantry.moveBefore ?? ctxPantry.insertBefore) 273 274const aliasedPreserveAttr = "preserve-attr" 275 276// syncs the oldNode to the newNode, copying over all attributes and 277// inner element state from the newNode to the oldNode 278const morphNode = ( 279 oldNode: Node, // root node to merge content into 280 newNode: Node, // new content to merge 281): Node => { 282 const type = newNode.nodeType 283 284 // if is an element type, sync the attributes from the 285 // new node into the new node 286 if (type === 1 /* element type */) { 287 const oldElt = oldNode as Element 288 const newElt = newNode as Element 289 if (oldElt.hasAttribute(aliasedIgnoreMorph) && newElt.hasAttribute(aliasedIgnoreMorph)) { 290 return oldNode 291 } 292 293 // many bothans died to bring us this information: 294 // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js 295 // https://github.com/choojs/nanomorph/blob/master/lib/morph.js#L113 296 if (oldElt instanceof HTMLInputElement && newElt instanceof HTMLInputElement && newElt.type !== "file") { 297 // https://github.com/bigskysoftware/idiomorph/issues/27 298 // | old input value | new input value | behaviour | 299 // | --------------- | ---------------- | -------------------------------------- | 300 // | `null` | `null` | preserve old input value | 301 // | some value | the same value | preserve old input value | 302 // | some value | `null` | set old input value to `""` | 303 // | `null` | some value | set old input value to new input value | 304 // | some value | some other value | set old input value to new input value | 305 if (newElt.getAttribute("value") !== oldElt.getAttribute("value")) { 306 oldElt.value = newElt.getAttribute("value") ?? "" 307 } 308 } else if (oldElt instanceof HTMLTextAreaElement && newElt instanceof HTMLTextAreaElement) { 309 if (newElt.value !== oldElt.value) { 310 oldElt.value = newElt.value 311 } 312 if (oldElt.firstChild && oldElt.firstChild.nodeValue !== newElt.value) { 313 oldElt.firstChild.nodeValue = newElt.value 314 } 315 } 316 317 const preserveAttrs = ((newNode as HTMLElement).getAttribute(aliasedPreserveAttr) ?? "").split(" ") 318 319 for (const { name, value } of newElt.attributes) { 320 if (oldElt.getAttribute(name) !== value && !preserveAttrs.includes(name)) { 321 oldElt.setAttribute(name, value) 322 } 323 } 324 325 for (let i = oldElt.attributes.length - 1; i >= 0; i--) { 326 const { name } = oldElt.attributes[i]! 327 if (!newElt.hasAttribute(name) && !preserveAttrs.includes(name)) { 328 oldElt.removeAttribute(name) 329 } 330 } 331 332 if (!oldElt.isEqualNode(newElt)) { 333 morphChildren(oldElt, newElt) 334 } 335 } 336 337 if (type === 8 /* comment */ || type === 3 /* text */) { 338 if (oldNode.nodeValue !== newNode.nodeValue) { 339 oldNode.nodeValue = newNode.nodeValue 340 } 341 } 342 343 return oldNode 344} 345 346// A bottom-up algorithm that populates a map of Element -> IdSet. 347// The ID set for a given element is the set of all IDs contained within its subtree. 348// As an optimization, we filter these IDs through the given list of persistent IDs, 349// because we don't need to bother considering IDed elements that won't be in the new content. 350const populateIdMapWithTree = (root: Element | ShadowRoot | null, elements: Iterable<Element>): void => { 351 for (const elt of elements) { 352 if (ctxPersistentIds.has(elt.id)) { 353 let current: Element | null = elt 354 // walk up the parent hierarchy of that element, adding the ID of element to the parent's ID set 355 while (current && current !== root) { 356 let idSet = ctxIdMap.get(current) 357 // if the ID set doesn’t exist, create it and insert it in the map 358 if (!idSet) { 359 idSet = new Set() 360 ctxIdMap.set(current, idSet) 361 } 362 idSet.add(elt.id) 363 current = current.parentElement 364 } 365 } 366 } 367}