Precise DOM morphing
morphing typescript dom

Optimise reorders

+436 -2
+44
benchmark/index.html
··· 727 }, 728 }, 729 { 730 name: "Deep Nesting", 731 description: "Morphing deeply nested structures", 732 setup: () => {
··· 727 }, 728 }, 729 { 730 + name: "Large List - Partial Reorder", 731 + description: "Reordering some items in a list of 100 items while keeping many in place", 732 + setup: () => { 733 + const from = document.createElement("ul") 734 + for (let i = 1; i <= 100; i++) { 735 + const li = document.createElement("li") 736 + li.id = `item-${i}` 737 + li.textContent = `Item ${i}` 738 + from.appendChild(li) 739 + } 740 + const to = document.createElement("ul") 741 + // Partial reorder: move every 5th item to a different position 742 + // [1,2,3,4,5,6,7,8,9,10,...] → [5,1,2,3,4,10,6,7,8,9,15,11,12,13,14,20,...] 743 + // This keeps most items in order (good for LIS) while shuffling some 744 + const items = [] 745 + for (let i = 1; i <= 100; i++) { 746 + items.push(i) 747 + } 748 + const reordered = [] 749 + for (let i = 0; i < items.length; i += 5) { 750 + if (i + 4 < items.length) { 751 + // Move 5th item to front of group 752 + reordered.push(items[i + 4]) 753 + reordered.push(items[i]) 754 + reordered.push(items[i + 1]) 755 + reordered.push(items[i + 2]) 756 + reordered.push(items[i + 3]) 757 + } else { 758 + // Handle remaining items 759 + for (let j = i; j < items.length; j++) { 760 + reordered.push(items[j]) 761 + } 762 + } 763 + } 764 + for (const num of reordered) { 765 + const li = document.createElement("li") 766 + li.id = `item-${num}` 767 + li.textContent = `Item ${num}` 768 + to.appendChild(li) 769 + } 770 + return { from, to } 771 + }, 772 + }, 773 + { 774 name: "Deep Nesting", 775 description: "Morphing deeply nested structures", 776 setup: () => {
+84 -2
src/morphlex.ts
··· 108 this.options = options 109 } 110 111 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { 112 if (isParentNode(from)) { 113 this.mapIdSets(from) ··· 286 const candidateNodes: Set<ChildNode> = new Set() 287 const candidateElements: Set<Element> = new Set() 288 289 - const matches: Array<ChildNode | null> = new Array(toChildNodes.length).fill(null) 290 291 for (const candidate of fromChildNodes) { 292 if (isElement(candidate)) candidateElements.add(candidate) ··· 428 } 429 } 430 431 // Process nodes in forward order to maintain proper positioning 432 let insertionPoint: ChildNode | null = parent.firstChild 433 for (let i = 0; i < toChildNodes.length; i++) { 434 const node = toChildNodes[i]! 435 const match = matches[i] 436 if (match) { 437 - moveBefore(parent, match, insertionPoint) 438 this.morphOneToOne(match, node) 439 insertionPoint = match.nextSibling 440 // Skip over any nodes that will be removed to avoid unnecessary moves
··· 108 this.options = options 109 } 110 111 + // Find longest increasing subsequence to minimize moves during reordering 112 + // Returns the indices in the sequence that form the LIS 113 + private longestIncreasingSubsequence(sequence: Array<number>): Array<number> { 114 + const n = sequence.length 115 + if (n === 0) return [] 116 + 117 + // smallestEnding[i] = smallest ending value of any increasing subsequence of length i+1 118 + const smallestEnding: Array<number> = [] 119 + // indices[i] = index in sequence where smallestEnding[i] occurs 120 + const indices: Array<number> = [] 121 + // prev[i] = previous index in the LIS ending at sequence[i] 122 + const prev: Array<number> = Array.from({ length: n }, () => -1) 123 + 124 + // Build the LIS by processing each value 125 + for (let i = 0; i < n; i++) { 126 + const val = sequence[i]! 127 + if (val === -1) continue // Skip new nodes (not in original sequence) 128 + 129 + // Binary search: find where this value fits in smallestEnding 130 + let left = 0 131 + let right = smallestEnding.length 132 + 133 + while (left < right) { 134 + const mid = Math.floor((left + right) / 2) 135 + if (smallestEnding[mid]! < val) left = mid + 1 136 + else right = mid 137 + } 138 + 139 + // Link this element to the previous one in the subsequence 140 + if (left > 0) prev[i] = indices[left - 1]! 141 + 142 + // Either extend the sequence or update an existing position 143 + if (left === smallestEnding.length) { 144 + // Extend: this value is larger than all previous endings 145 + smallestEnding.push(val) 146 + indices.push(i) 147 + } else { 148 + // Update: found a better (smaller) ending for this length 149 + smallestEnding[left] = val 150 + indices[left] = i 151 + } 152 + } 153 + 154 + // Reconstruct the actual indices that form the LIS 155 + const result: Array<number> = [] 156 + if (indices.length === 0) return result 157 + 158 + // Walk backwards through prev links to build the LIS 159 + let curr: number | undefined = indices[indices.length - 1] 160 + while (curr !== undefined && curr !== -1) { 161 + result.unshift(curr) 162 + curr = prev[curr] 163 + } 164 + 165 + return result 166 + } 167 + 168 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { 169 if (isParentNode(from)) { 170 this.mapIdSets(from) ··· 343 const candidateNodes: Set<ChildNode> = new Set() 344 const candidateElements: Set<Element> = new Set() 345 346 + const matches: Array<ChildNode | null> = Array.from({ length: toChildNodes.length }, () => null) 347 348 for (const candidate of fromChildNodes) { 349 if (isElement(candidate)) candidateElements.add(candidate) ··· 485 } 486 } 487 488 + // Build sequence of current indices for LIS calculation 489 + const fromIndex = new Map<ChildNode, number>() 490 + Array.from(fromChildNodes).forEach((node, i) => fromIndex.set(node, i)) 491 + 492 + const sequence: Array<number> = [] 493 + for (let i = 0; i < matches.length; i++) { 494 + const match = matches[i] 495 + if (match && fromIndex.has(match)) { 496 + sequence.push(fromIndex.get(match)!) 497 + } else { 498 + sequence.push(-1) // New node, not in sequence 499 + } 500 + } 501 + 502 + // Find LIS - these nodes don't need to move 503 + const lisIndices = this.longestIncreasingSubsequence(sequence) 504 + const shouldNotMove = new Set<number>() 505 + for (const idx of lisIndices) { 506 + shouldNotMove.add(sequence[idx]!) 507 + } 508 + 509 // Process nodes in forward order to maintain proper positioning 510 let insertionPoint: ChildNode | null = parent.firstChild 511 for (let i = 0; i < toChildNodes.length; i++) { 512 const node = toChildNodes[i]! 513 const match = matches[i] 514 if (match) { 515 + const matchIndex = fromIndex.get(match)! 516 + // Only move if not in LIS 517 + if (!shouldNotMove.has(matchIndex)) { 518 + moveBefore(parent, match, insertionPoint) 519 + } 520 this.morphOneToOne(match, node) 521 insertionPoint = match.nextSibling 522 // Skip over any nodes that will be removed to avoid unnecessary moves
+51
test-mutations.html
···
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <body> 4 + <div id="test"></div> 5 + <script type="module"> 6 + import { morph } from './dist/morphlex.min.js'; 7 + 8 + const observer = new MutationObserver((mutations) => { 9 + console.log(`Total mutations: ${mutations.length}`); 10 + mutations.forEach((m, i) => { 11 + console.log(`Mutation ${i}: type=${m.type}, added=${m.addedNodes.length}, removed=${m.removedNodes.length}`); 12 + }); 13 + }); 14 + 15 + const from = document.createElement('ul'); 16 + for (let i = 1; i <= 5; i++) { 17 + const li = document.createElement('li'); 18 + li.textContent = `Item ${i}`; 19 + from.appendChild(li); 20 + } 21 + 22 + const to = document.createElement('ul'); 23 + for (let i = 5; i >= 1; i--) { 24 + const li = document.createElement('li'); 25 + li.textContent = `Item ${i}`; 26 + to.appendChild(li); 27 + } 28 + 29 + document.getElementById('test').appendChild(from); 30 + 31 + observer.observe(from, { 32 + childList: true, 33 + attributes: true, 34 + characterData: true, 35 + subtree: true 36 + }); 37 + 38 + console.log('Before morph:'); 39 + console.log(Array.from(from.children).map(li => li.textContent)); 40 + 41 + morph(from, to); 42 + 43 + console.log('After morph:'); 44 + console.log(Array.from(from.children).map(li => li.textContent)); 45 + 46 + setTimeout(() => { 47 + observer.disconnect(); 48 + }, 100); 49 + </script> 50 + </body> 51 + </html>
+257
test/optimal-reordering.browser.test.ts
···
··· 1 + import { describe, test, expect } from "vitest" 2 + import { morph } from "../src/morphlex" 3 + 4 + describe("Optimal Reordering", () => { 5 + test("should minimize moves when reordering - simple rotation", async () => { 6 + const from = document.createElement("ul") 7 + for (let i = 1; i <= 5; i++) { 8 + const li = document.createElement("li") 9 + li.id = `item-${i}` 10 + li.textContent = `Item ${i}` 11 + from.appendChild(li) 12 + } 13 + 14 + const to = document.createElement("ul") 15 + // Rotate: move last to first [1,2,3,4,5] → [5,1,2,3,4] 16 + for (const id of [5, 1, 2, 3, 4]) { 17 + const li = document.createElement("li") 18 + li.id = `item-${id}` 19 + li.textContent = `Item ${id}` 20 + to.appendChild(li) 21 + } 22 + 23 + document.body.appendChild(from) 24 + 25 + const mutations: MutationRecord[] = [] 26 + const observer = new MutationObserver((records) => { 27 + mutations.push(...records) 28 + }) 29 + 30 + observer.observe(from, { 31 + childList: true, 32 + subtree: true, 33 + }) 34 + 35 + morph(from, to) 36 + 37 + await new Promise((resolve) => setTimeout(resolve, 0)) 38 + 39 + observer.disconnect() 40 + document.body.removeChild(from) 41 + 42 + // With LIS optimization: 43 + // Sequence: [4, 0, 1, 2, 3] (current indices in desired order) 44 + // LIS: [0, 1, 2, 3] (items 1,2,3,4 stay in order) 45 + // Only item 5 needs to move! 46 + // Expected: 2 mutations (1 remove + 1 add for moving item 5) 47 + 48 + const childListMutations = mutations.filter((m) => m.type === "childList") 49 + console.log(`\nRotation test: ${childListMutations.length} childList mutations`) 50 + 51 + // Currently fails with 2 moves (4 mutations) 52 + // Should pass with 1 move (2 mutations) after LIS optimization 53 + expect(childListMutations.length).toBe(2) 54 + }) 55 + 56 + test("should minimize moves when reordering - partial reorder", async () => { 57 + const from = document.createElement("ul") 58 + for (let i = 1; i <= 10; i++) { 59 + const li = document.createElement("li") 60 + li.id = `item-${i}` 61 + li.textContent = `Item ${i}` 62 + from.appendChild(li) 63 + } 64 + 65 + const to = document.createElement("ul") 66 + // [1,2,3,4,5,6,7,8,9,10] → [3,1,5,7,2,8,4,9,6,10] 67 + // Items in LIS: 3,5,7,8,9,10 (6 items stay) 68 + // Items to move: 1,2,4,6 (4 items) 69 + for (const id of [3, 1, 5, 7, 2, 8, 4, 9, 6, 10]) { 70 + const li = document.createElement("li") 71 + li.id = `item-${id}` 72 + li.textContent = `Item ${id}` 73 + to.appendChild(li) 74 + } 75 + 76 + document.body.appendChild(from) 77 + 78 + const mutations: MutationRecord[] = [] 79 + const observer = new MutationObserver((records) => { 80 + mutations.push(...records) 81 + }) 82 + 83 + observer.observe(from, { 84 + childList: true, 85 + subtree: true, 86 + }) 87 + 88 + morph(from, to) 89 + 90 + await new Promise((resolve) => setTimeout(resolve, 0)) 91 + 92 + observer.disconnect() 93 + document.body.removeChild(from) 94 + 95 + // Sequence: [2, 0, 4, 6, 1, 7, 3, 8, 5, 9] 96 + // LIS: [2, 4, 6, 7, 8, 9] length 6 (items 3,5,7,8,9,10) 97 + // Move: 10 - 6 = 4 items 98 + // Expected: 8 mutations (4 moves × 2) 99 + 100 + const childListMutations = mutations.filter((m) => m.type === "childList") 101 + console.log(`\nPartial reorder test: ${childListMutations.length} childList mutations`) 102 + 103 + expect(childListMutations.length).toBeLessThanOrEqual(8) 104 + }) 105 + 106 + test("should minimize moves when reordering - complete reversal", async () => { 107 + const from = document.createElement("ul") 108 + for (let i = 1; i <= 6; i++) { 109 + const li = document.createElement("li") 110 + li.id = `item-${i}` 111 + li.textContent = `Item ${i}` 112 + from.appendChild(li) 113 + } 114 + 115 + const to = document.createElement("ul") 116 + // Complete reversal [1,2,3,4,5,6] → [6,5,4,3,2,1] 117 + for (let i = 6; i >= 1; i--) { 118 + const li = document.createElement("li") 119 + li.id = `item-${i}` 120 + li.textContent = `Item ${i}` 121 + to.appendChild(li) 122 + } 123 + 124 + document.body.appendChild(from) 125 + 126 + const mutations: MutationRecord[] = [] 127 + const observer = new MutationObserver((records) => { 128 + mutations.push(...records) 129 + }) 130 + 131 + observer.observe(from, { 132 + childList: true, 133 + subtree: true, 134 + }) 135 + 136 + morph(from, to) 137 + 138 + await new Promise((resolve) => setTimeout(resolve, 0)) 139 + 140 + observer.disconnect() 141 + document.body.removeChild(from) 142 + 143 + // Sequence: [5, 4, 3, 2, 1, 0] (completely decreasing) 144 + // LIS: any single element, length 1 145 + // Move: 6 - 1 = 5 items 146 + // Expected: 10 mutations (5 moves × 2) 147 + 148 + const childListMutations = mutations.filter((m) => m.type === "childList") 149 + console.log(`\nReversal test: ${childListMutations.length} childList mutations`) 150 + 151 + // This is actually optimal for a reversal - can't do better than moving 5 items 152 + expect(childListMutations.length).toBeLessThanOrEqual(10) 153 + }) 154 + 155 + test("should minimize moves when reordering - already optimal", async () => { 156 + const from = document.createElement("ul") 157 + for (let i = 1; i <= 5; i++) { 158 + const li = document.createElement("li") 159 + li.id = `item-${i}` 160 + li.textContent = `Item ${i}` 161 + from.appendChild(li) 162 + } 163 + 164 + const to = document.createElement("ul") 165 + // [1,2,3,4,5] → [1,2,4,5,3] 166 + // Move only item 3 to the end 167 + for (const id of [1, 2, 4, 5, 3]) { 168 + const li = document.createElement("li") 169 + li.id = `item-${id}` 170 + li.textContent = `Item ${id}` 171 + to.appendChild(li) 172 + } 173 + 174 + document.body.appendChild(from) 175 + 176 + const mutations: MutationRecord[] = [] 177 + const observer = new MutationObserver((records) => { 178 + mutations.push(...records) 179 + }) 180 + 181 + observer.observe(from, { 182 + childList: true, 183 + subtree: true, 184 + }) 185 + 186 + morph(from, to) 187 + 188 + await new Promise((resolve) => setTimeout(resolve, 0)) 189 + 190 + observer.disconnect() 191 + document.body.removeChild(from) 192 + 193 + // Sequence: [0, 1, 3, 4, 2] 194 + // LIS: [0, 1, 3, 4] length 4 (items 1,2,4,5) 195 + // Move: only item 3 196 + // Expected: 2 mutations (1 move × 2) 197 + 198 + const childListMutations = mutations.filter((m) => m.type === "childList") 199 + console.log(`\nAlready optimal test: ${childListMutations.length} childList mutations`) 200 + 201 + expect(childListMutations.length).toBe(2) 202 + }) 203 + 204 + test("should minimize moves with mixed operations", async () => { 205 + const from = document.createElement("ul") 206 + for (let i = 1; i <= 8; i++) { 207 + const li = document.createElement("li") 208 + li.id = `item-${i}` 209 + li.textContent = `Item ${i}` 210 + from.appendChild(li) 211 + } 212 + 213 + const to = document.createElement("ul") 214 + // Remove 2 and 6, add 9 and 10, reorder rest 215 + // [1,2,3,4,5,6,7,8] → [4,1,9,5,7,3,10,8] 216 + for (const id of [4, 1, 9, 5, 7, 3, 10, 8]) { 217 + const li = document.createElement("li") 218 + li.id = `item-${id}` 219 + li.textContent = `Item ${id}` 220 + to.appendChild(li) 221 + } 222 + 223 + document.body.appendChild(from) 224 + 225 + const mutations: MutationRecord[] = [] 226 + const observer = new MutationObserver((records) => { 227 + mutations.push(...records) 228 + }) 229 + 230 + observer.observe(from, { 231 + childList: true, 232 + subtree: true, 233 + }) 234 + 235 + morph(from, to) 236 + 237 + await new Promise((resolve) => setTimeout(resolve, 0)) 238 + 239 + observer.disconnect() 240 + document.body.removeChild(from) 241 + 242 + // Matched items: [4,1,5,7,3,8] at indices [3,0,4,6,2,7] 243 + // Sequence: [3, 0, 4, 6, 2, 7] 244 + // LIS: [3, 4, 6, 7] length 4 (items 4,5,7,8) 245 + // Move: 6 - 4 = 2 items (1 and 3) 246 + // Plus: 2 removals (2,6) and 2 additions (9,10) 247 + // Expected: ~8 mutations (2 moves + 2 removes + 2 adds = 6 ops × variable mutations) 248 + 249 + const childListMutations = mutations.filter((m) => m.type === "childList") 250 + console.log(`\nMixed operations test: ${childListMutations.length} childList mutations`) 251 + 252 + // Just verify it completes correctly 253 + expect(from.children.length).toBe(8) 254 + expect(from.children[0]?.id).toBe("item-4") 255 + expect(from.children[7]?.id).toBe("item-8") 256 + }) 257 + })