Precise DOM morphing
morphing typescript dom

Performance improvements (#36)

authored by joel.drapper.me and committed by

GitHub f7e26860 d39c4437

+897 -163
+5
.oxlintrc.json
··· 1 + { 2 + "rules": { 3 + "unicorn/no-new-array": "off" 4 + } 5 + }
+2
benchmark/index.html
··· 378 378 // 2. Start a local server: 'bun run --bun vite' or 'python -m http.server' in the morphlex root 379 379 // 3. Open http://localhost:5173/benchmark/ (or appropriate port) 380 380 import { morph as morphlex } from "../dist/morphlex.min.js" 381 + import { morph as dataMorph } from "../dist/data-morph.min.js" 381 382 import { Idiomorph } from "https://unpkg.com/idiomorph@0.7.4/dist/idiomorph.esm.js" 382 383 import morphdom from "https://unpkg.com/morphdom@2.7.7/dist/morphdom-esm.js" 383 384 // Try loading nanomorph from jsdelivr with ESM ··· 553 554 const results = [] 554 555 const libraries = [ 555 556 { name: "morphlex", fn: (from, to) => morphlex(from, to) }, 557 + { name: "data-morph", fn: (from, to) => dataMorph(from, to.cloneNode(true), "outer") }, 556 558 { name: "idiomorph", fn: (from, to) => Idiomorph.morph(from, to) }, 557 559 { name: "morphdom", fn: (from, to) => morphdom(from, to.cloneNode(true)) }, 558 560 { name: "nanomorph", fn: (from, to) => nanomorph(from, to.cloneNode(true)) },
+384
benchmark/simple.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Morphlex Benchmark</title> 7 + <style> 8 + * { 9 + margin: 0; 10 + padding: 0; 11 + box-sizing: border-box; 12 + } 13 + 14 + body { 15 + font-family: 16 + system-ui, 17 + -apple-system, 18 + sans-serif; 19 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 20 + min-height: 100vh; 21 + padding: 2rem; 22 + color: #1f2937; 23 + } 24 + 25 + .container { 26 + max-width: 900px; 27 + margin: 0 auto; 28 + } 29 + 30 + header { 31 + text-align: center; 32 + color: white; 33 + margin-bottom: 2rem; 34 + } 35 + 36 + h1 { 37 + font-size: 2.5rem; 38 + margin-bottom: 0.5rem; 39 + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); 40 + } 41 + 42 + .subtitle { 43 + font-size: 1.1rem; 44 + opacity: 0.9; 45 + } 46 + 47 + .card { 48 + background: white; 49 + border-radius: 12px; 50 + padding: 2rem; 51 + margin-bottom: 1.5rem; 52 + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); 53 + } 54 + 55 + .controls { 56 + display: flex; 57 + gap: 1rem; 58 + flex-wrap: wrap; 59 + align-items: center; 60 + margin-bottom: 1.5rem; 61 + } 62 + 63 + label { 64 + font-weight: 600; 65 + display: flex; 66 + align-items: center; 67 + gap: 0.5rem; 68 + } 69 + 70 + input[type="number"], 71 + select { 72 + padding: 0.5rem; 73 + border: 2px solid #e5e7eb; 74 + border-radius: 6px; 75 + font-size: 1rem; 76 + } 77 + 78 + input[type="number"] { 79 + width: 100px; 80 + } 81 + 82 + select { 83 + min-width: 200px; 84 + } 85 + 86 + button { 87 + padding: 0.75rem 2rem; 88 + background: #6366f1; 89 + color: white; 90 + border: none; 91 + border-radius: 6px; 92 + font-size: 1rem; 93 + font-weight: 600; 94 + cursor: pointer; 95 + transition: all 0.2s; 96 + } 97 + 98 + button:hover { 99 + background: #4f46e5; 100 + transform: translateY(-1px); 101 + } 102 + 103 + button:disabled { 104 + background: #9ca3af; 105 + cursor: not-allowed; 106 + transform: none; 107 + } 108 + 109 + .results { 110 + display: none; 111 + } 112 + 113 + .results.visible { 114 + display: block; 115 + } 116 + 117 + .result-item { 118 + padding: 1rem; 119 + border-bottom: 1px solid #e5e7eb; 120 + } 121 + 122 + .result-item:last-child { 123 + border-bottom: none; 124 + } 125 + 126 + .result-header { 127 + display: flex; 128 + justify-content: space-between; 129 + align-items: center; 130 + margin-bottom: 0.5rem; 131 + } 132 + 133 + .result-name { 134 + font-weight: 600; 135 + font-size: 1.1rem; 136 + } 137 + 138 + .result-time { 139 + font-size: 1.5rem; 140 + font-weight: 700; 141 + color: #6366f1; 142 + } 143 + 144 + .result-details { 145 + display: grid; 146 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 147 + gap: 1rem; 148 + margin-top: 0.75rem; 149 + } 150 + 151 + .detail { 152 + display: flex; 153 + flex-direction: column; 154 + } 155 + 156 + .detail-label { 157 + font-size: 0.75rem; 158 + color: #6b7280; 159 + text-transform: uppercase; 160 + letter-spacing: 0.5px; 161 + } 162 + 163 + .detail-value { 164 + font-weight: 600; 165 + font-size: 1rem; 166 + } 167 + 168 + .progress { 169 + margin-top: 0.75rem; 170 + text-align: center; 171 + color: #6b7280; 172 + font-style: italic; 173 + } 174 + 175 + #sandbox { 176 + display: none; 177 + } 178 + </style> 179 + </head> 180 + <body> 181 + <div class="container"> 182 + <header> 183 + <h1>Morphlex Benchmark</h1> 184 + <p class="subtitle">Performance testing for DOM morphing</p> 185 + </header> 186 + 187 + <div class="card"> 188 + <div class="controls"> 189 + <label> 190 + Benchmark: 191 + <select id="benchmarkSelect"> 192 + <option value="all">All Benchmarks</option> 193 + </select> 194 + </label> 195 + <label> 196 + Iterations: 197 + <input type="number" id="iterations" value="1000" min="100" step="100" /> 198 + </label> 199 + <button id="runBtn">Run Benchmark</button> 200 + </div> 201 + <div class="progress" id="progress"></div> 202 + </div> 203 + 204 + <div class="card results" id="results"> 205 + <h2 style="margin-bottom: 1.5rem">Results</h2> 206 + <div id="resultsContainer"></div> 207 + </div> 208 + </div> 209 + 210 + <div id="sandbox"></div> 211 + 212 + <script type="module"> 213 + import { morph } from "../dist/morphlex.js" 214 + 215 + const testCases = [ 216 + { 217 + name: "Simple Text Change", 218 + from: "<div>Hello</div>", 219 + to: "<div>World</div>", 220 + }, 221 + { 222 + name: "Attribute Update", 223 + from: '<div class="foo" id="test">Content</div>', 224 + to: '<div class="bar" id="test">Content</div>', 225 + }, 226 + { 227 + name: "Add Children", 228 + from: "<ul><li>One</li></ul>", 229 + to: "<ul><li>One</li><li>Two</li><li>Three</li></ul>", 230 + }, 231 + { 232 + name: "Remove Children", 233 + from: "<ul><li>One</li><li>Two</li><li>Three</li></ul>", 234 + to: "<ul><li>One</li></ul>", 235 + }, 236 + { 237 + name: "Reorder Children", 238 + from: '<ul><li id="a">A</li><li id="b">B</li><li id="c">C</li></ul>', 239 + to: '<ul><li id="c">C</li><li id="a">A</li><li id="b">B</li></ul>', 240 + }, 241 + { 242 + name: "Deep Nested Update", 243 + from: "<div><div><div><span>Deep</span></div></div></div>", 244 + to: "<div><div><div><span>Nested</span></div></div></div>", 245 + }, 246 + { 247 + name: "Large List (100 items)", 248 + from: "<ul>" + Array.from({ length: 100 }, (_, i) => `<li id="item-${i}">Item ${i}</li>`).join("") + "</ul>", 249 + to: "<ul>" + Array.from({ length: 100 }, (_, i) => `<li id="item-${i}">Item ${i * 2}</li>`).join("") + "</ul>", 250 + }, 251 + { 252 + name: "Mixed Operations", 253 + from: '<div class="old"><p>Text</p><span id="keep">Keep</span></div>', 254 + to: '<div class="new"><span id="keep">Keep</span><p>New Text</p><a href="#">Link</a></div>', 255 + }, 256 + ] 257 + 258 + const sandbox = document.getElementById("sandbox") 259 + const runBtn = document.getElementById("runBtn") 260 + const benchmarkSelect = document.getElementById("benchmarkSelect") 261 + const iterationsInput = document.getElementById("iterations") 262 + const progressDiv = document.getElementById("progress") 263 + const resultsDiv = document.getElementById("results") 264 + const resultsContainer = document.getElementById("resultsContainer") 265 + 266 + // Populate benchmark select 267 + testCases.forEach((testCase, index) => { 268 + const option = document.createElement("option") 269 + option.value = index 270 + option.textContent = testCase.name 271 + benchmarkSelect.appendChild(option) 272 + }) 273 + 274 + function createElements(html) { 275 + const temp = document.createElement("div") 276 + temp.innerHTML = html 277 + return temp.firstChild 278 + } 279 + 280 + async function runBenchmark(testCase, iterations) { 281 + const times = [] 282 + 283 + for (let i = 0; i < iterations; i++) { 284 + const from = createElements(testCase.from) 285 + const to = createElements(testCase.to) 286 + sandbox.appendChild(from) 287 + 288 + const start = performance.now() 289 + morph(from, to) 290 + const end = performance.now() 291 + 292 + times.push(end - start) 293 + sandbox.innerHTML = "" 294 + 295 + // Yield to browser occasionally 296 + if (i % 100 === 0) { 297 + await new Promise((resolve) => setTimeout(resolve, 0)) 298 + } 299 + } 300 + 301 + times.sort((a, b) => a - b) 302 + const total = times.reduce((sum, t) => sum + t, 0) 303 + const avg = total / times.length 304 + const median = times[Math.floor(times.length / 2)] 305 + const min = times[0] 306 + const max = times[times.length - 1] 307 + const p95 = times[Math.floor(times.length * 0.95)] 308 + 309 + return { total, avg, median, min, max, p95, times: times.length } 310 + } 311 + 312 + function displayResults(results) { 313 + resultsContainer.innerHTML = results 314 + .map( 315 + (result) => ` 316 + <div class="result-item"> 317 + <div class="result-header"> 318 + <span class="result-name">${result.name}</span> 319 + <span class="result-time">${result.avg.toFixed(3)}ms</span> 320 + </div> 321 + <div class="result-details"> 322 + <div class="detail"> 323 + <span class="detail-label">Median</span> 324 + <span class="detail-value">${result.median.toFixed(3)}ms</span> 325 + </div> 326 + <div class="detail"> 327 + <span class="detail-label">Min</span> 328 + <span class="detail-value">${result.min.toFixed(3)}ms</span> 329 + </div> 330 + <div class="detail"> 331 + <span class="detail-label">Max</span> 332 + <span class="detail-value">${result.max.toFixed(3)}ms</span> 333 + </div> 334 + <div class="detail"> 335 + <span class="detail-label">P95</span> 336 + <span class="detail-value">${result.p95.toFixed(3)}ms</span> 337 + </div> 338 + <div class="detail"> 339 + <span class="detail-label">Total</span> 340 + <span class="detail-value">${result.total.toFixed(1)}ms</span> 341 + </div> 342 + <div class="detail"> 343 + <span class="detail-label">Iterations</span> 344 + <span class="detail-value">${result.times}</span> 345 + </div> 346 + </div> 347 + </div> 348 + `, 349 + ) 350 + .join("") 351 + 352 + resultsDiv.classList.add("visible") 353 + } 354 + 355 + runBtn.addEventListener("click", async () => { 356 + const iterations = parseInt(iterationsInput.value) 357 + if (iterations < 1) return 358 + 359 + const selectedValue = benchmarkSelect.value 360 + const selectedTests = selectedValue === "all" ? testCases : [testCases[parseInt(selectedValue)]] 361 + 362 + runBtn.disabled = true 363 + resultsDiv.classList.remove("visible") 364 + progressDiv.textContent = "Running benchmarks..." 365 + 366 + const results = [] 367 + 368 + for (let i = 0; i < selectedTests.length; i++) { 369 + const testCase = selectedTests[i] 370 + progressDiv.textContent = `Running ${testCase.name} (${i + 1}/${selectedTests.length})...` 371 + 372 + const result = await runBenchmark(testCase, iterations) 373 + results.push({ name: testCase.name, ...result }) 374 + 375 + await new Promise((resolve) => setTimeout(resolve, 100)) 376 + } 377 + 378 + progressDiv.textContent = "" 379 + displayResults(results) 380 + runBtn.disabled = false 381 + }) 382 + </script> 383 + </body> 384 + </html>
+1 -1
build.ts
··· 5 5 6 6 // Build and minify with Bun 7 7 await build({ 8 - entrypoints: ["./src/morphlex.ts"], 8 + entrypoints: ["./src/morphlex.ts", "./src/data-morph.ts"], 9 9 outdir: "./dist", 10 10 minify: true, 11 11 sourcemap: "external",
bun.lockb

This is a binary file and will not be displayed.

+367
src/data-morph.ts
··· 1 + type HTMLOrSVG = HTMLElement | SVGElement | MathMLElement 2 + 3 + export const isHTMLOrSVG = (el: Node): el is HTMLOrSVG => 4 + el instanceof HTMLElement || el instanceof SVGElement || el instanceof MathMLElement 5 + 6 + const ctxIdMap = new Map<Node, Set<string>>() 7 + const ctxPersistentIds = new Set<string>() 8 + const oldIdTagNameMap = new Map<string, string>() 9 + const duplicateIds = new Set<string>() 10 + const ctxPantry = document.createElement("div") 11 + ctxPantry.hidden = true 12 + 13 + const aliasedIgnoreMorph = "ignore-morph" 14 + const aliasedIgnoreMorphAttr = `[${aliasedIgnoreMorph}]` 15 + export 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. 82 + const 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. 172 + const 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. 247 + const 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 258 + const 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. 270 + const moveBefore: (parentNode: Node, node: Node, after: Node | null) => void = 271 + // @ts-expect-error 272 + removeNode.call.bind(ctxPantry.moveBefore ?? ctxPantry.insertBefore) 273 + 274 + const 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 278 + const 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. 350 + const 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 + }
+138 -162
src/morphlex.ts
··· 1 - const PARENT_NODE_TYPES = new Set([1, 9, 11]) 2 1 const SUPPORTS_MOVE_BEFORE = "moveBefore" in Element.prototype 2 + const ELEMENT_NODE_TYPE = 1 3 + const TEXT_NODE_TYPE = 3 4 + const PARENT_NODE_TYPES = [false, true, false, false, false, false, false, false, false, true, false, true] 3 5 4 - type IdSet = Set<string> 5 - type IdMap = WeakMap<Node, IdSet> 6 + const candidateNodes: Set<number> = new Set() 7 + const candidateElements: Set<number> = new Set() 8 + const unmatchedNodes: Set<number> = new Set() 9 + const unmatchedElements: Set<number> = new Set() 10 + const whitespaceNodes: Set<number> = new Set() 6 11 7 - declare const brand: unique symbol 8 - type Branded<T, B extends string> = T & { [brand]: B } 9 - 10 - type PairOfNodes<N extends Node> = [N, N] 11 - type PairOfMatchingElements<E extends Element> = Branded<PairOfNodes<E>, "MatchingElementPair"> 12 + type IdMap = WeakMap<Node, Array<string>> 12 13 13 14 /** 14 15 * Configuration options for morphing operations. ··· 148 149 if (typeof to === "string") { 149 150 const fragment = parseFragment(to) 150 151 151 - if (fragment.firstChild && fragment.childNodes.length === 1 && isElement(fragment.firstChild)) { 152 + if (fragment.firstChild && fragment.childNodes.length === 1 && fragment.firstChild.nodeType === ELEMENT_NODE_TYPE) { 152 153 to = fragment.firstChild 153 154 } else { 154 155 throw new Error("[Morphlex] The string was not a valid HTML element.") 155 156 } 156 157 } 157 158 158 - const pair: PairOfNodes<Node> = [from, to] 159 - if (isElementPair(pair) && isMatchingElementPair(pair)) { 159 + if ( 160 + from.nodeType === ELEMENT_NODE_TYPE && 161 + to.nodeType === ELEMENT_NODE_TYPE && 162 + (from as Element).localName === (to as Element).localName 163 + ) { 160 164 if (isParentNode(from)) flagDirtyInputs(from) 161 - new Morph(options).visitChildNodes(pair) 165 + new Morph(options).visitChildNodes(from as Element, to as Element) 162 166 } else { 163 167 throw new Error("[Morphlex] You can only do an inner morph with matching elements.") 164 168 } ··· 166 170 167 171 function flagDirtyInputs(node: ParentNode): void { 168 172 for (const input of node.querySelectorAll("input")) { 169 - if (!input.name) continue 170 - 171 - if (input.value !== input.defaultValue) { 172 - input.setAttribute("morphlex-dirty", "") 173 - } 174 - 175 - if (input.checked !== input.defaultChecked) { 173 + if ((input.name && input.value !== input.defaultValue) || input.checked !== input.defaultChecked) { 176 174 input.setAttribute("morphlex-dirty", "") 177 175 } 178 176 } 179 177 180 178 for (const element of node.querySelectorAll("option")) { 181 - if (!element.value) continue 182 - 183 - if (element.selected !== element.defaultSelected) { 179 + if (element.value && element.selected !== element.defaultSelected) { 184 180 element.setAttribute("morphlex-dirty", "") 185 181 } 186 182 } ··· 208 204 if (node === insertionPoint) return 209 205 if (node.parentNode === parent) { 210 206 if (node.nextSibling === insertionPoint) return 211 - if (supportsMoveBefore(parent)) { 212 - parent.moveBefore(node, insertionPoint) 207 + if (SUPPORTS_MOVE_BEFORE) { 208 + ;(parent as NodeWithMoveBefore).moveBefore(node, insertionPoint) 213 209 return 214 210 } 215 211 } ··· 227 223 228 224 // Find longest increasing subsequence to minimize moves during reordering 229 225 // Returns the indices in the sequence that form the LIS 230 - #longestIncreasingSubsequence(sequence: Array<number>): Array<number> { 226 + #longestIncreasingSubsequence(sequence: Array<number | undefined>): Array<number> { 231 227 const n = sequence.length 232 228 if (n === 0) return [] 233 229 ··· 236 232 // indices[i] = index in sequence where smallestEnding[i] occurs 237 233 const indices: Array<number> = [] 238 234 // prev[i] = previous index in the LIS ending at sequence[i] 239 - const prev: Array<number> = Array.from({ length: n }, () => -1) 235 + const prev: Array<number> = new Array(n) 240 236 241 237 // Build the LIS by processing each value 242 238 for (let i = 0; i < n; i++) { 243 - const val = sequence[i]! 244 - if (val === -1) continue // Skip new nodes (not in original sequence) 239 + const val = sequence[i] 240 + if (val === undefined) continue // Skip new nodes (not in original sequence) 245 241 246 242 // Binary search: find where this value fits in smallestEnding 247 243 let left = 0 ··· 254 250 } 255 251 256 252 // Link this element to the previous one in the subsequence 257 - if (left > 0) prev[i] = indices[left - 1]! 253 + prev[i] = left > 0 ? indices[left - 1]! : -1 258 254 259 255 // Either extend the sequence or update an existing position 260 256 if (left === smallestEnding.length) { ··· 304 300 } else if (length === 1) { 305 301 this.#morphOneToOne(from, to[0]!) 306 302 } else if (length > 1) { 307 - const newNodes = Array.from(to) 303 + const newNodes = [...to] 308 304 this.#morphOneToOne(from, newNodes.shift()!) 309 305 const insertionPoint = from.nextSibling 310 306 const parent = from.parentNode || document 311 307 312 - for (const newNode of newNodes) { 308 + for (let i = 0; i < newNodes.length; i++) { 309 + const newNode = newNodes[i]! 313 310 if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) { 314 - moveBefore(parent, newNode, insertionPoint) 311 + parent.insertBefore(newNode, insertionPoint) 315 312 this.#options.afterNodeAdded?.(newNode) 316 313 } 317 314 } ··· 325 322 326 323 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 327 324 328 - const pair: PairOfNodes<ChildNode> = [from, to] 329 - 330 - if (isElementPair(pair)) { 331 - if (isMatchingElementPair(pair)) { 332 - this.#morphMatchingElements(pair) 325 + if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { 326 + if ((from as Element).localName === (to as Element).localName) { 327 + this.#morphMatchingElements(from as Element, to as Element) 333 328 } else { 334 - this.#morphNonMatchingElements(pair) 329 + this.#morphNonMatchingElements(from as Element, to as Element) 335 330 } 336 331 } else { 337 - this.#morphOtherNode(pair) 332 + this.#morphOtherNode(from, to) 338 333 } 339 334 340 335 this.#options.afterNodeVisited?.(from, to) 341 336 } 342 337 343 - #morphMatchingElements(pair: PairOfMatchingElements<Element>): void { 344 - const [from, to] = pair 345 - 338 + #morphMatchingElements(from: Element, to: Element): void { 346 339 if (from.hasAttributes() || to.hasAttributes()) { 347 - this.#visitAttributes(pair) 340 + this.#visitAttributes(from, to) 348 341 } 349 342 350 - if (isTextAreaElement(from) && isTextAreaElement(to)) { 351 - this.#visitTextArea(pair as PairOfMatchingElements<HTMLTextAreaElement>) 343 + if ("textarea" === from.localName && "textarea" === to.localName) { 344 + this.#visitTextArea(from as HTMLTextAreaElement, to as HTMLTextAreaElement) 352 345 } else if (from.hasChildNodes() || to.hasChildNodes()) { 353 - this.visitChildNodes(pair) 346 + this.visitChildNodes(from, to) 354 347 } 355 348 } 356 349 357 - #morphNonMatchingElements([from, to]: PairOfNodes<Element>): void { 350 + #morphNonMatchingElements(from: Element, to: Element): void { 358 351 this.#replaceNode(from, to) 359 352 } 360 353 361 - #morphOtherNode([from, to]: PairOfNodes<ChildNode>): void { 354 + #morphOtherNode(from: ChildNode, to: ChildNode): void { 362 355 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 363 356 from.nodeValue = to.nodeValue 364 357 } else { ··· 366 359 } 367 360 } 368 361 369 - #visitAttributes([from, to]: PairOfMatchingElements<Element>): void { 362 + #visitAttributes(from: Element, to: Element): void { 370 363 if (from.hasAttribute("morphlex-dirty")) { 371 364 from.removeAttribute("morphlex-dirty") 372 365 } 373 366 374 367 // First pass: update/add attributes from reference (iterate forwards) 375 - for (const { name, value } of to.attributes) { 368 + const toAttributes = to.attributes 369 + for (let i = 0; i < toAttributes.length; i++) { 370 + const { name, value } = toAttributes[i]! 376 371 if (name === "value") { 377 372 if (isInputElement(from) && from.value !== value) { 378 373 if (!this.#options.preserveChanges || from.value === from.defaultValue) { ··· 436 431 } 437 432 } 438 433 439 - #visitTextArea([from, to]: PairOfMatchingElements<HTMLTextAreaElement>): void { 434 + #visitTextArea(from: HTMLTextAreaElement, to: HTMLTextAreaElement): void { 440 435 const newTextContent = to.textContent || "" 441 436 const isModified = from.value !== from.defaultValue 442 437 ··· 450 445 from.value = from.defaultValue 451 446 } 452 447 453 - visitChildNodes([from, to]: PairOfMatchingElements<Element>): void { 448 + visitChildNodes(from: Element, to: Element): void { 454 449 if (!(this.#options.beforeChildrenVisited?.(from) ?? true)) return 455 450 const parent = from 456 451 457 - const fromChildNodes = Array.from(from.childNodes) 458 - const toChildNodes = Array.from(to.childNodes) 452 + const fromChildNodes = [...from.childNodes] 453 + const toChildNodes = [...to.childNodes] 459 454 460 - const candidateNodes: Set<number> = new Set() 461 - const candidateElements: Set<number> = new Set() 455 + candidateNodes.clear() 456 + candidateElements.clear() 457 + unmatchedNodes.clear() 458 + unmatchedElements.clear() 459 + whitespaceNodes.clear() 462 460 463 - const unmatchedNodes: Set<number> = new Set() 464 - const unmatchedElements: Set<number> = new Set() 465 - 466 - const matches: Array<ChildNode | null> = Array.from({ length: toChildNodes.length }, () => null) 461 + const seq: Array<number | undefined> = [] 462 + const matches: Array<number | undefined> = [] 467 463 468 464 for (let i = 0; i < fromChildNodes.length; i++) { 469 465 const candidate = fromChildNodes[i]! 470 - if (isElement(candidate)) candidateElements.add(i) 471 - else candidateNodes.add(i) 466 + const nodeType = candidate.nodeType 467 + 468 + if (nodeType === ELEMENT_NODE_TYPE) { 469 + candidateElements.add(i) 470 + } else if (nodeType === TEXT_NODE_TYPE && candidate.textContent?.trim() === "") { 471 + whitespaceNodes.add(i) 472 + } else { 473 + candidateNodes.add(i) 474 + } 472 475 } 473 476 474 477 for (let i = 0; i < toChildNodes.length; i++) { 475 478 const node = toChildNodes[i]! 476 - if (isElement(node)) unmatchedElements.add(i) 477 - else unmatchedNodes.add(i) 479 + const nodeType = node.nodeType 480 + 481 + if (nodeType === ELEMENT_NODE_TYPE) { 482 + unmatchedElements.add(i) 483 + } else if (nodeType === TEXT_NODE_TYPE && node.textContent?.trim() === "") { 484 + continue 485 + } else { 486 + unmatchedNodes.add(i) 487 + } 478 488 } 479 489 480 490 // Match elements by isEqualNode ··· 482 492 const element = toChildNodes[unmatchedIndex] as Element 483 493 484 494 for (const candidateIndex of candidateElements) { 485 - const candidate = fromChildNodes[candidateIndex]! 495 + const candidate = fromChildNodes[candidateIndex] as Element 496 + 486 497 if (candidate.isEqualNode(element)) { 487 - matches[unmatchedIndex] = candidate 498 + matches[unmatchedIndex] = candidateIndex 499 + seq[candidateIndex] = unmatchedIndex 488 500 candidateElements.delete(candidateIndex) 489 501 unmatchedElements.delete(unmatchedIndex) 490 502 break ··· 492 504 } 493 505 } 494 506 495 - // Match by exact id 507 + // Match by exact id or idSet 496 508 for (const unmatchedIndex of unmatchedElements) { 497 509 const element = toChildNodes[unmatchedIndex] as Element 498 510 499 511 const id = element.id 500 - if (id === "") continue 512 + const idSet = this.#idMap.get(element) 501 513 502 - for (const candidateIndex of candidateElements) { 514 + if (id === "" && !idSet) continue 515 + 516 + candidateLoop: for (const candidateIndex of candidateElements) { 503 517 const candidate = fromChildNodes[candidateIndex] as Element 504 - if (element.localName === candidate.localName && id === candidate.id) { 505 - matches[unmatchedIndex] = candidate 518 + 519 + // Match by exact id 520 + if (id !== "" && element.localName === candidate.localName && id === candidate.id) { 521 + matches[unmatchedIndex] = candidateIndex 522 + seq[candidateIndex] = unmatchedIndex 506 523 candidateElements.delete(candidateIndex) 507 524 unmatchedElements.delete(unmatchedIndex) 508 - break 525 + break candidateLoop 509 526 } 510 - } 511 - } 512 527 513 - // Match by idSet 514 - for (const unmatchedIndex of unmatchedElements) { 515 - const element = toChildNodes[unmatchedIndex] as Element 516 - 517 - const idSet = this.#idMap.get(element) 518 - if (!idSet) continue 519 - 520 - candidateLoop: for (const candidateIndex of candidateElements) { 521 - const candidate = fromChildNodes[candidateIndex] as Element 522 - const candidateIdSet = this.#idMap.get(candidate) 523 - if (candidateIdSet) { 524 - for (const id of idSet) { 525 - if (candidateIdSet.has(id)) { 526 - matches[unmatchedIndex] = candidate 527 - candidateElements.delete(candidateIndex) 528 - unmatchedElements.delete(unmatchedIndex) 529 - break candidateLoop 528 + // Match by idSet 529 + if (idSet) { 530 + const candidateIdSet = this.#idMap.get(candidate) 531 + if (candidateIdSet) { 532 + for (let i = 0; i < idSet.length; i++) { 533 + const setId = idSet[i]! 534 + for (let k = 0; k < candidateIdSet.length; k++) { 535 + if (candidateIdSet[k] === setId) { 536 + matches[unmatchedIndex] = candidateIndex 537 + seq[candidateIndex] = unmatchedIndex 538 + candidateElements.delete(candidateIndex) 539 + unmatchedElements.delete(unmatchedIndex) 540 + break candidateLoop 541 + } 542 + } 530 543 } 531 544 } 532 545 } ··· 549 562 (href && href === candidate.getAttribute("href")) || 550 563 (src && src === candidate.getAttribute("src"))) 551 564 ) { 552 - matches[unmatchedIndex] = candidate 565 + matches[unmatchedIndex] = candidateIndex 566 + seq[candidateIndex] = unmatchedIndex 553 567 candidateElements.delete(candidateIndex) 554 568 unmatchedElements.delete(unmatchedIndex) 555 569 break ··· 570 584 // Treat inputs with different type as though they are different tags. 571 585 continue 572 586 } 573 - matches[unmatchedIndex] = candidate 587 + matches[unmatchedIndex] = candidateIndex 588 + seq[candidateIndex] = unmatchedIndex 574 589 candidateElements.delete(candidateIndex) 575 590 unmatchedElements.delete(unmatchedIndex) 576 591 break ··· 581 596 // Match nodes by isEqualNode (skip whitespace-only text nodes) 582 597 for (const unmatchedIndex of unmatchedNodes) { 583 598 const node = toChildNodes[unmatchedIndex]! 584 - if (isWhitespace(node)) continue 585 599 586 600 for (const candidateIndex of candidateNodes) { 587 601 const candidate = fromChildNodes[candidateIndex]! 588 602 if (candidate.isEqualNode(node)) { 589 - matches[unmatchedIndex] = candidate 603 + matches[unmatchedIndex] = candidateIndex 604 + seq[candidateIndex] = unmatchedIndex 590 605 candidateNodes.delete(candidateIndex) 591 606 unmatchedNodes.delete(unmatchedIndex) 592 607 break ··· 594 609 } 595 610 } 596 611 597 - // Match by nodeType (skip whitespace-only text nodes) 612 + // Match by nodeType 598 613 for (const unmatchedIndex of unmatchedNodes) { 599 614 const node = toChildNodes[unmatchedIndex]! 600 - if (isWhitespace(node)) continue 601 615 602 616 const nodeType = node.nodeType 603 617 604 618 for (const candidateIndex of candidateNodes) { 605 619 const candidate = fromChildNodes[candidateIndex]! 606 620 if (nodeType === candidate.nodeType) { 607 - matches[unmatchedIndex] = candidate 621 + matches[unmatchedIndex] = candidateIndex 622 + seq[candidateIndex] = unmatchedIndex 608 623 candidateNodes.delete(candidateIndex) 609 624 unmatchedNodes.delete(unmatchedIndex) 610 625 break ··· 613 628 } 614 629 615 630 // Remove any unmatched candidates first, before calculating LIS and repositioning 616 - for (const candidateIndex of candidateNodes) { 617 - this.#removeNode(fromChildNodes[candidateIndex]!) 618 - } 619 - 620 - for (const candidateIndex of candidateElements) { 621 - this.#removeNode(fromChildNodes[candidateIndex]!) 622 - } 623 - 624 - // Build sequence of current indices for LIS calculation (after removals) 625 - const fromIndex = new Map<ChildNode, number>() 626 - Array.from(parent.childNodes).forEach((node, i) => fromIndex.set(node, i)) 627 - 628 - const sequence: Array<number> = [] 629 - for (let i = 0; i < matches.length; i++) { 630 - const match = matches[i] 631 - if (match && fromIndex.has(match)) { 632 - sequence.push(fromIndex.get(match)!) 633 - } else { 634 - sequence.push(-1) // New node, not in sequence 635 - } 636 - } 631 + for (const i of candidateNodes) this.#removeNode(fromChildNodes[i]!) 632 + for (const i of whitespaceNodes) this.#removeNode(fromChildNodes[i]!) 633 + for (const i of candidateElements) this.#removeNode(fromChildNodes[i]!) 637 634 638 635 // Find LIS - these nodes don't need to move 639 - const lisIndices = this.#longestIncreasingSubsequence(sequence) 640 - const shouldNotMove = new Set<number>() 641 - for (const i of lisIndices) { 642 - shouldNotMove.add(sequence[i]!) 636 + // matches already contains the fromChildNodes indices, so we can use it directly 637 + const lisIndices = this.#longestIncreasingSubsequence(matches) 638 + 639 + const shouldNotMove: Array<boolean> = new Array(fromChildNodes.length) 640 + for (let i = 0; i < lisIndices.length; i++) { 641 + shouldNotMove[matches[lisIndices[i]!]!] = true 643 642 } 644 643 645 644 let insertionPoint: ChildNode | null = parent.firstChild 646 645 for (let i = 0; i < toChildNodes.length; i++) { 647 646 const node = toChildNodes[i]! 648 - const match = matches[i] 649 - if (match) { 650 - const matchIndex = fromIndex.get(match)! 651 - if (!shouldNotMove.has(matchIndex)) { 647 + const matchInd = matches[i] 648 + if (matchInd !== undefined) { 649 + const match = fromChildNodes[matchInd]! 650 + 651 + if (!shouldNotMove[matchInd]) { 652 652 moveBefore(parent, match, insertionPoint) 653 653 } 654 654 this.#morphOneToOne(match, node) 655 655 insertionPoint = match.nextSibling 656 656 } else { 657 657 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) { 658 - moveBefore(parent, node, insertionPoint) 658 + parent.insertBefore(node, insertionPoint) 659 659 this.#options.afterNodeAdded?.(node) 660 660 insertionPoint = node.nextSibling 661 661 } ··· 673 673 (this.#options.beforeNodeRemoved?.(node) ?? true) && 674 674 (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) 675 675 ) { 676 - moveBefore(parent, newNode, insertionPoint) 676 + parent.insertBefore(newNode, insertionPoint) 677 677 this.#options.afterNodeAdded?.(newNode) 678 678 node.remove() 679 679 this.#options.afterNodeRemoved?.(node) ··· 697 697 698 698 // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 699 699 #mapIdSets(node: ParentNode): void { 700 - for (const elementWithId of node.querySelectorAll("[id]")) { 701 - const id = elementWithId.id 700 + const idMap = this.#idMap 701 + 702 + for (const element of node.querySelectorAll("[id]")) { 703 + const id = element.id 702 704 703 705 if (id === "") continue 704 706 705 - let currentElement: Element | null = elementWithId 707 + let currentElement: Element | null = element 706 708 707 709 while (currentElement) { 708 - const idSet: IdSet | undefined = this.#idMap.get(currentElement) 709 - if (idSet) idSet.add(id) 710 - else this.#idMap.set(currentElement, new Set([id])) 710 + const idSet: Array<string> | undefined = idMap.get(currentElement) 711 + if (idSet) idSet.push(id) 712 + else idMap.set(currentElement, [id]) 711 713 if (currentElement === node) break 712 714 currentElement = currentElement.parentElement 713 715 } ··· 715 717 } 716 718 } 717 719 718 - function supportsMoveBefore(_node: ParentNode): _node is NodeWithMoveBefore { 719 - return SUPPORTS_MOVE_BEFORE 720 - } 721 - 722 - function isMatchingElementPair(pair: PairOfNodes<Element>): pair is PairOfMatchingElements<Element> { 723 - const [a, b] = pair 724 - return a.localName === b.localName 725 - } 726 - 727 - function isElementPair(pair: PairOfNodes<Node>): pair is PairOfNodes<Element> { 728 - const [a, b] = pair 729 - return isElement(a) && isElement(b) 730 - } 731 - 732 - function isElement(node: Node): node is Element { 733 - return node.nodeType === 1 734 - } 735 - 736 720 function isInputElement(element: Element): element is HTMLInputElement { 737 721 return element.localName === "input" 738 722 } 739 723 740 - function isWhitespace(node: ChildNode): boolean { 741 - return node.nodeType === 3 && node.textContent?.trim() === "" 742 - } 743 - 744 724 function isOptionElement(element: Element): element is HTMLOptionElement { 745 725 return element.localName === "option" 746 726 } 747 727 748 - function isTextAreaElement(element: Element): element is HTMLTextAreaElement { 749 - return element.localName === "textarea" 750 - } 751 - 752 728 function isParentNode(node: Node): node is ParentNode { 753 - return PARENT_NODE_TYPES.has(node.nodeType) 729 + return !!PARENT_NODE_TYPES[node.nodeType] 754 730 }