Precise DOM morphing
morphing typescript dom

Optimise removal whitespace

+635 -478
+1 -1
.gitignore
··· 3 3 dist 4 4 coverage 5 5 reference 6 - test/__screenshots__ 6 + test/**/__screenshots__
+29 -27
src/morphlex.ts
··· 453 453 } 454 454 } 455 455 456 - // Match nodes by isEqualNode 456 + // Match nodes by isEqualNode (skip whitespace-only text nodes) 457 457 for (let i = 0; i < toChildNodes.length; i++) { 458 458 if (matches[i]) continue 459 459 const node = toChildNodes[i]! 460 460 if (isElement(node)) continue 461 + if (isWhitespace(node)) continue 461 462 462 463 for (const candidate of candidateNodes) { 463 464 if (candidate.isEqualNode(node)) { ··· 468 469 } 469 470 } 470 471 471 - // Match by nodeType 472 + // Match by nodeType (skip whitespace-only text nodes) 472 473 for (let i = 0; i < toChildNodes.length; i++) { 473 474 if (matches[i]) continue 474 475 const node = toChildNodes[i]! 475 476 if (isElement(node)) continue 477 + if (isWhitespace(node)) continue 476 478 477 479 const nodeType = node.nodeType 478 480 ··· 485 487 } 486 488 } 487 489 488 - // Build sequence of current indices for LIS calculation 490 + // Remove unmatched nodes from candidate sets (they were matched and should not be removed) 491 + for (const match of matches) { 492 + if (match) { 493 + candidateNodes.delete(match) 494 + if (isElement(match)) { 495 + candidateElements.delete(match) 496 + } 497 + } 498 + } 499 + 500 + // Remove any unmatched candidates first, before calculating LIS and repositioning 501 + for (const candidate of candidateNodes) { 502 + this.#removeNode(candidate) 503 + } 504 + 505 + for (const candidate of candidateElements) { 506 + this.#removeNode(candidate) 507 + } 508 + 509 + // Build sequence of current indices for LIS calculation (after removals) 489 510 const fromIndex = new Map<ChildNode, number>() 490 - Array.from(fromChildNodes).forEach((node, i) => fromIndex.set(node, i)) 511 + Array.from(parent.childNodes).forEach((node, i) => fromIndex.set(node, i)) 491 512 492 513 const sequence: Array<number> = [] 493 514 for (let i = 0; i < matches.length; i++) { ··· 519 540 } 520 541 this.#morphOneToOne(match, node) 521 542 insertionPoint = match.nextSibling 522 - // Skip over any nodes that will be removed to avoid unnecessary moves 523 - while ( 524 - insertionPoint && 525 - (candidateNodes.has(insertionPoint) || (isElement(insertionPoint) && candidateElements.has(insertionPoint))) 526 - ) { 527 - insertionPoint = insertionPoint.nextSibling 528 - } 529 543 } else { 530 544 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) { 531 545 moveBefore(parent, node, insertionPoint) 532 546 this.#options.afterNodeAdded?.(node) 533 547 insertionPoint = node.nextSibling 534 - // Skip over any nodes that will be removed to avoid unnecessary moves 535 - while ( 536 - insertionPoint && 537 - (candidateNodes.has(insertionPoint) || (isElement(insertionPoint) && candidateElements.has(insertionPoint))) 538 - ) { 539 - insertionPoint = insertionPoint.nextSibling 540 - } 541 548 } 542 549 } 543 - } 544 - 545 - // Remove any remaining unmatched candidates 546 - for (const candidate of candidateNodes) { 547 - this.#removeNode(candidate) 548 - } 549 - 550 - for (const candidate of candidateElements) { 551 - this.#removeNode(candidate) 552 550 } 553 551 554 552 this.#options.afterChildrenVisited?.(from) ··· 619 617 620 618 function isInputElement(element: Element): element is HTMLInputElement { 621 619 return element.localName === "input" 620 + } 621 + 622 + function isWhitespace(node: ChildNode): boolean { 623 + return node.nodeType === 3 && node.textContent?.trim() === "" 622 624 } 623 625 624 626 function isOptionElement(element: Element): element is HTMLOptionElement {
-193
test/inputs.browser.test.ts
··· 1 - import { test, expect, describe } from "vitest" 2 - import { morph } from "../src/morphlex" 3 - 4 - function parseHTML(html: string): HTMLElement { 5 - const tmp = document.createElement("div") 6 - tmp.innerHTML = html.trim() 7 - return tmp.firstChild as HTMLElement 8 - } 9 - 10 - describe("text input", () => { 11 - test("morphing a modified value with preserveModified enabled", () => { 12 - const a = parseHTML(`<input type="text" value="a">`) as HTMLInputElement 13 - const b = parseHTML(`<input type="text" value="b">`) as HTMLInputElement 14 - 15 - a.value = "c" 16 - morph(a, b, { preserveModified: true }) 17 - 18 - expect(a.outerHTML).toBe(`<input type="text" value="b">`) 19 - expect(a.value).toBe("c") 20 - }) 21 - 22 - test("morphing a modified value preserveModified disabled", () => { 23 - const a = parseHTML(`<input type="text" value="a">`) as HTMLInputElement 24 - const b = parseHTML(`<input type="text" value="b">`) as HTMLInputElement 25 - 26 - a.value = "c" 27 - morph(a, b, { preserveModified: false }) 28 - 29 - expect(a.outerHTML).toBe(`<input type="text" value="b">`) 30 - expect(a.value).toBe("b") 31 - }) 32 - 33 - test("morphing an unmodified value with preserveModified enabled", () => { 34 - const a = parseHTML(`<input type="text" value="a">`) as HTMLInputElement 35 - const b = parseHTML(`<input type="text" value="b">`) as HTMLInputElement 36 - 37 - morph(a, b, { preserveModified: true }) 38 - 39 - expect(a.outerHTML).toBe(`<input type="text" value="b">`) 40 - expect(a.value).toBe("b") 41 - }) 42 - }) 43 - 44 - describe("checkbox", () => { 45 - test("morphing a modified checkbox checked with preserveModified enabled", () => { 46 - const a = parseHTML(`<input type="checkbox">`) as HTMLInputElement 47 - const b = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement 48 - 49 - a.checked = true 50 - morph(a, b, { preserveModified: true }) 51 - 52 - expect(a.hasAttribute("checked")).toBe(true) 53 - expect(a.checked).toBe(true) 54 - }) 55 - 56 - test("morphing a modified checkbox checked with preserveModified disabled", () => { 57 - const a = parseHTML(`<input type="checkbox">`) as HTMLInputElement 58 - const b = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement 59 - 60 - a.checked = true 61 - morph(a, b, { preserveModified: false }) 62 - 63 - expect(a.hasAttribute("checked")).toBe(true) 64 - expect(a.checked).toBe(true) 65 - }) 66 - 67 - test("morphing an unmodified checkbox with preserveModified enabled", () => { 68 - const a = parseHTML(`<input type="checkbox">`) as HTMLInputElement 69 - const b = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement 70 - 71 - morph(a, b, { preserveModified: true }) 72 - 73 - expect(a.hasAttribute("checked")).toBe(true) 74 - expect(a.checked).toBe(true) 75 - }) 76 - 77 - test("morphing a modified checkbox unchecked with preserveModified enabled", () => { 78 - const a = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement 79 - const b = parseHTML(`<input type="checkbox">`) as HTMLInputElement 80 - 81 - a.checked = false 82 - morph(a, b, { preserveModified: true }) 83 - 84 - expect(a.hasAttribute("checked")).toBe(false) 85 - expect(a.checked).toBe(false) 86 - }) 87 - 88 - test("morphing a modified checkbox unchecked with preserveModified disabled", () => { 89 - const a = parseHTML(`<input type="checkbox" checked>`) as HTMLInputElement 90 - const b = parseHTML(`<input type="checkbox">`) as HTMLInputElement 91 - 92 - a.checked = false 93 - morph(a, b, { preserveModified: false }) 94 - 95 - expect(a.hasAttribute("checked")).toBe(false) 96 - expect(a.checked).toBe(false) 97 - }) 98 - }) 99 - 100 - describe("select", () => { 101 - test("morphing a modified select option with preserveModified enabled", () => { 102 - const a = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 103 - const b = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 104 - 105 - a.value = "b" 106 - morph(a, b, { preserveModified: true }) 107 - 108 - expect(a.options[1].hasAttribute("selected")).toBe(true) 109 - expect(a.value).toBe("b") 110 - expect(a.options[1].selected).toBe(true) 111 - }) 112 - 113 - test("morphing a modified select option with preserveModified disabled", () => { 114 - const a = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 115 - const b = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 116 - 117 - a.value = "b" 118 - morph(a, b, { preserveModified: false }) 119 - 120 - expect(a.options[1].hasAttribute("selected")).toBe(true) 121 - expect(a.value).toBe("b") 122 - expect(a.options[1].selected).toBe(true) 123 - }) 124 - 125 - test("morphing an unmodified select option with preserveModified enabled", () => { 126 - const a = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 127 - const b = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 128 - 129 - morph(a, b, { preserveModified: true }) 130 - 131 - expect(a.options[1].hasAttribute("selected")).toBe(true) 132 - expect(a.value).toBe("b") 133 - expect(a.options[1].selected).toBe(true) 134 - }) 135 - 136 - test("morphing a modified select option back to default with preserveModified enabled", () => { 137 - const a = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 138 - const b = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 139 - 140 - a.value = "a" 141 - morph(a, b, { preserveModified: true }) 142 - 143 - expect(a.options[1].hasAttribute("selected")).toBe(false) 144 - expect(a.value).toBe("a") 145 - expect(a.options[0].selected).toBe(true) 146 - }) 147 - 148 - test("morphing a modified select option back to default with preserveModified disabled", () => { 149 - const a = parseHTML(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 150 - const b = parseHTML(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 151 - 152 - a.value = "a" 153 - morph(a, b, { preserveModified: false }) 154 - 155 - expect(a.options[1].hasAttribute("selected")).toBe(false) 156 - expect(a.value).toBe("a") 157 - expect(a.options[0].selected).toBe(true) 158 - }) 159 - }) 160 - 161 - describe("textarea", () => { 162 - test("morphing a modified textarea value with preserveModified enabled", () => { 163 - const a = parseHTML(`<textarea>a</textarea>`) as HTMLTextAreaElement 164 - const b = parseHTML(`<textarea>b</textarea>`) as HTMLTextAreaElement 165 - 166 - a.value = "c" 167 - morph(a, b, { preserveModified: true }) 168 - 169 - expect(a.textContent).toBe("b") 170 - expect(a.value).toBe("c") 171 - }) 172 - 173 - test("morphing a modified textarea value with preserveModified disabled", () => { 174 - const a = parseHTML(`<textarea>a</textarea>`) as HTMLTextAreaElement 175 - const b = parseHTML(`<textarea>b</textarea>`) as HTMLTextAreaElement 176 - 177 - a.value = "c" 178 - morph(a, b, { preserveModified: false }) 179 - 180 - expect(a.textContent).toBe("b") 181 - expect(a.value).toBe("b") 182 - }) 183 - 184 - test("morphing an unmodified textarea value with preserveModified enabled", () => { 185 - const a = parseHTML(`<textarea>a</textarea>`) as HTMLTextAreaElement 186 - const b = parseHTML(`<textarea>b</textarea>`) as HTMLTextAreaElement 187 - 188 - morph(a, b, { preserveModified: true }) 189 - 190 - expect(a.textContent).toBe("b") 191 - expect(a.value).toBe("b") 192 - }) 193 - })
+188
test/new/inputs.browser.test.ts
··· 1 + import { test, expect, describe } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom } from "./utils" 4 + 5 + describe("text input", () => { 6 + test("morphing a modified value with preserveModified enabled", () => { 7 + const a = dom(`<input type="text" value="a">`) as HTMLInputElement 8 + const b = dom(`<input type="text" value="b">`) as HTMLInputElement 9 + 10 + a.value = "c" 11 + morph(a, b, { preserveModified: true }) 12 + 13 + expect(a.outerHTML).toBe(`<input type="text" value="b">`) 14 + expect(a.value).toBe("c") 15 + }) 16 + 17 + test("morphing a modified value preserveModified disabled", () => { 18 + const a = dom(`<input type="text" value="a">`) as HTMLInputElement 19 + const b = dom(`<input type="text" value="b">`) as HTMLInputElement 20 + 21 + a.value = "c" 22 + morph(a, b, { preserveModified: false }) 23 + 24 + expect(a.outerHTML).toBe(`<input type="text" value="b">`) 25 + expect(a.value).toBe("b") 26 + }) 27 + 28 + test("morphing an unmodified value with preserveModified enabled", () => { 29 + const a = dom(`<input type="text" value="a">`) as HTMLInputElement 30 + const b = dom(`<input type="text" value="b">`) as HTMLInputElement 31 + 32 + morph(a, b, { preserveModified: true }) 33 + 34 + expect(a.outerHTML).toBe(`<input type="text" value="b">`) 35 + expect(a.value).toBe("b") 36 + }) 37 + }) 38 + 39 + describe("checkbox", () => { 40 + test("morphing a modified checkbox checked with preserveModified enabled", () => { 41 + const a = dom(`<input type="checkbox">`) as HTMLInputElement 42 + const b = dom(`<input type="checkbox" checked>`) as HTMLInputElement 43 + 44 + a.checked = true 45 + morph(a, b, { preserveModified: true }) 46 + 47 + expect(a.hasAttribute("checked")).toBe(true) 48 + expect(a.checked).toBe(true) 49 + }) 50 + 51 + test("morphing a modified checkbox checked with preserveModified disabled", () => { 52 + const a = dom(`<input type="checkbox">`) as HTMLInputElement 53 + const b = dom(`<input type="checkbox" checked>`) as HTMLInputElement 54 + 55 + a.checked = true 56 + morph(a, b, { preserveModified: false }) 57 + 58 + expect(a.hasAttribute("checked")).toBe(true) 59 + expect(a.checked).toBe(true) 60 + }) 61 + 62 + test("morphing an unmodified checkbox with preserveModified enabled", () => { 63 + const a = dom(`<input type="checkbox">`) as HTMLInputElement 64 + const b = dom(`<input type="checkbox" checked>`) as HTMLInputElement 65 + 66 + morph(a, b, { preserveModified: true }) 67 + 68 + expect(a.hasAttribute("checked")).toBe(true) 69 + expect(a.checked).toBe(true) 70 + }) 71 + 72 + test("morphing a modified checkbox unchecked with preserveModified enabled", () => { 73 + const a = dom(`<input type="checkbox" checked>`) as HTMLInputElement 74 + const b = dom(`<input type="checkbox">`) as HTMLInputElement 75 + 76 + a.checked = false 77 + morph(a, b, { preserveModified: true }) 78 + 79 + expect(a.hasAttribute("checked")).toBe(false) 80 + expect(a.checked).toBe(false) 81 + }) 82 + 83 + test("morphing a modified checkbox unchecked with preserveModified disabled", () => { 84 + const a = dom(`<input type="checkbox" checked>`) as HTMLInputElement 85 + const b = dom(`<input type="checkbox">`) as HTMLInputElement 86 + 87 + a.checked = false 88 + morph(a, b, { preserveModified: false }) 89 + 90 + expect(a.hasAttribute("checked")).toBe(false) 91 + expect(a.checked).toBe(false) 92 + }) 93 + }) 94 + 95 + describe("select", () => { 96 + test("morphing a modified select option with preserveModified enabled", () => { 97 + const a = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 98 + const b = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 99 + 100 + a.value = "b" 101 + morph(a, b, { preserveModified: true }) 102 + 103 + expect(a.options[1].hasAttribute("selected")).toBe(true) 104 + expect(a.value).toBe("b") 105 + expect(a.options[1].selected).toBe(true) 106 + }) 107 + 108 + test("morphing a modified select option with preserveModified disabled", () => { 109 + const a = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 110 + const b = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 111 + 112 + a.value = "b" 113 + morph(a, b, { preserveModified: false }) 114 + 115 + expect(a.options[1].hasAttribute("selected")).toBe(true) 116 + expect(a.value).toBe("b") 117 + expect(a.options[1].selected).toBe(true) 118 + }) 119 + 120 + test("morphing an unmodified select option with preserveModified enabled", () => { 121 + const a = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 122 + const b = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 123 + 124 + morph(a, b, { preserveModified: true }) 125 + 126 + expect(a.options[1].hasAttribute("selected")).toBe(true) 127 + expect(a.value).toBe("b") 128 + expect(a.options[1].selected).toBe(true) 129 + }) 130 + 131 + test("morphing a modified select option back to default with preserveModified enabled", () => { 132 + const a = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 133 + const b = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 134 + 135 + a.value = "a" 136 + morph(a, b, { preserveModified: true }) 137 + 138 + expect(a.options[1].hasAttribute("selected")).toBe(false) 139 + expect(a.value).toBe("a") 140 + expect(a.options[0].selected).toBe(true) 141 + }) 142 + 143 + test("morphing a modified select option back to default with preserveModified disabled", () => { 144 + const a = dom(`<select><option value="a">A</option><option value="b" selected>B</option></select>`) as HTMLSelectElement 145 + const b = dom(`<select><option value="a">A</option><option value="b">B</option></select>`) as HTMLSelectElement 146 + 147 + a.value = "a" 148 + morph(a, b, { preserveModified: false }) 149 + 150 + expect(a.options[1].hasAttribute("selected")).toBe(false) 151 + expect(a.value).toBe("a") 152 + expect(a.options[0].selected).toBe(true) 153 + }) 154 + }) 155 + 156 + describe("textarea", () => { 157 + test("morphing a modified textarea value with preserveModified enabled", () => { 158 + const a = dom(`<textarea>a</textarea>`) as HTMLTextAreaElement 159 + const b = dom(`<textarea>b</textarea>`) as HTMLTextAreaElement 160 + 161 + a.value = "c" 162 + morph(a, b, { preserveModified: true }) 163 + 164 + expect(a.textContent).toBe("b") 165 + expect(a.value).toBe("c") 166 + }) 167 + 168 + test("morphing a modified textarea value with preserveModified disabled", () => { 169 + const a = dom(`<textarea>a</textarea>`) as HTMLTextAreaElement 170 + const b = dom(`<textarea>b</textarea>`) as HTMLTextAreaElement 171 + 172 + a.value = "c" 173 + morph(a, b, { preserveModified: false }) 174 + 175 + expect(a.textContent).toBe("b") 176 + expect(a.value).toBe("b") 177 + }) 178 + 179 + test("morphing an unmodified textarea value with preserveModified enabled", () => { 180 + const a = dom(`<textarea>a</textarea>`) as HTMLTextAreaElement 181 + const b = dom(`<textarea>b</textarea>`) as HTMLTextAreaElement 182 + 183 + morph(a, b, { preserveModified: true }) 184 + 185 + expect(a.textContent).toBe("b") 186 + expect(a.value).toBe("b") 187 + }) 188 + })
+81
test/new/insert.browser.test.ts
··· 1 + import { test, expect } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom, observeMutations } from "./utils" 4 + 5 + test("insert item at the end of a list", () => { 6 + const from = dom(` 7 + <ul> 8 + <li>Item 1</li> 9 + <li>Item 2</li> 10 + </ul> 11 + `) 12 + 13 + const to = dom(` 14 + <ul> 15 + <li>Item 1</li> 16 + <li>Item 2</li> 17 + <li>New Item</li> 18 + </ul> 19 + `) 20 + 21 + const expected = to.outerHTML 22 + 23 + const mutations = observeMutations(from, () => { 24 + morph(from, to) 25 + }) 26 + 27 + expect(from.outerHTML).toBe(expected) 28 + expect(mutations.elementsAdded).toBe(1) 29 + }) 30 + 31 + test("insert item at the beginning of a list", () => { 32 + const from = dom(` 33 + <ul> 34 + <li>Item 1</li> 35 + <li>Item 2</li> 36 + </ul> 37 + `) 38 + 39 + const to = dom(` 40 + <ul> 41 + <li>New Item</li> 42 + <li>Item 1</li> 43 + <li>Item 2</li> 44 + </ul> 45 + `) 46 + 47 + const expected = to.outerHTML 48 + 49 + const mutations = observeMutations(from, () => { 50 + morph(from, to) 51 + }) 52 + 53 + expect(from.outerHTML).toBe(expected) 54 + expect(mutations.elementsAdded).toBe(1) 55 + }) 56 + 57 + test("insert item in the middle of a list", () => { 58 + const from = dom(` 59 + <ul> 60 + <li>Item 1</li> 61 + <li>Item 2</li> 62 + </ul> 63 + `) 64 + 65 + const to = dom(` 66 + <ul> 67 + <li>Item 1</li> 68 + <li>New Item</li> 69 + <li>Item 2</li> 70 + </ul> 71 + `) 72 + 73 + const expected = to.outerHTML 74 + 75 + const mutations = observeMutations(from, () => { 76 + morph(from, to) 77 + }) 78 + 79 + expect(from.outerHTML).toBe(expected) 80 + expect(mutations.elementsAdded).toBe(1) 81 + })
+81
test/new/remove.browser.test.ts
··· 1 + import { test, expect } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom, observeMutations } from "./utils" 4 + 5 + test("remove item from the end of a list", () => { 6 + const from = dom(` 7 + <ul> 8 + <li>Item 1</li> 9 + <li>Item 2</li> 10 + <li>Item 3</li> 11 + </ul> 12 + `) 13 + 14 + const to = dom(` 15 + <ul> 16 + <li>Item 1</li> 17 + <li>Item 2</li> 18 + </ul> 19 + `) 20 + 21 + const expected = to.outerHTML 22 + 23 + const mutations = observeMutations(from, () => { 24 + morph(from, to) 25 + }) 26 + 27 + expect(from.outerHTML).toBe(expected) 28 + expect(mutations.elementsRemoved).toBe(1) 29 + }) 30 + 31 + test("remove item from the beginning of a list", () => { 32 + const from = dom(` 33 + <ul> 34 + <li>Item 1</li> 35 + <li>Item 2</li> 36 + <li>Item 3</li> 37 + </ul> 38 + `) 39 + 40 + const to = dom(` 41 + <ul> 42 + <li>Item 2</li> 43 + <li>Item 3</li> 44 + </ul> 45 + `) 46 + 47 + const expected = to.outerHTML 48 + 49 + const mutations = observeMutations(from, () => { 50 + morph(from, to) 51 + }) 52 + 53 + expect(from.outerHTML).toBe(expected) 54 + expect(mutations.elementsRemoved).toBe(1) 55 + }) 56 + 57 + test("remove item from the middle of a list", () => { 58 + const from = dom(` 59 + <ul> 60 + <li>Item 1</li> 61 + <li>Item 2</li> 62 + <li>Item 3</li> 63 + </ul> 64 + `) 65 + 66 + const to = dom(` 67 + <ul> 68 + <li>Item 1</li> 69 + <li>Item 3</li> 70 + </ul> 71 + `) 72 + 73 + const expected = to.outerHTML 74 + 75 + const mutations = observeMutations(from, () => { 76 + morph(from, to) 77 + }) 78 + 79 + expect(from.outerHTML).toBe(expected) 80 + expect(mutations.elementsRemoved).toBe(1) 81 + })
+117
test/new/reordering.browser.test.ts
··· 1 + import { describe, test, expect } from "vitest" 2 + import { morph } from "../../src/morphlex" 3 + import { dom, observeMutations } from "./utils" 4 + 5 + describe("Optimal Reordering", () => { 6 + test("should minimize moves when reordering - simple rotation", () => { 7 + const from = document.createElement("ul") 8 + for (let i = 1; i <= 5; i++) { 9 + from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`)) 10 + } 11 + 12 + const to = document.createElement("ul") 13 + for (const id of [5, 1, 2, 3, 4]) { 14 + to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`)) 15 + } 16 + 17 + document.body.appendChild(from) 18 + 19 + const mutations = observeMutations(from, () => { 20 + morph(from, to) 21 + }) 22 + 23 + document.body.removeChild(from) 24 + 25 + expect(mutations.childListChanges).toBe(2) 26 + }) 27 + 28 + test("should minimize moves when reordering - partial reorder", () => { 29 + const from = document.createElement("ul") 30 + for (let i = 1; i <= 10; i++) { 31 + from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`)) 32 + } 33 + 34 + const to = document.createElement("ul") 35 + for (const id of [3, 1, 5, 7, 2, 8, 4, 9, 6, 10]) { 36 + to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`)) 37 + } 38 + 39 + document.body.appendChild(from) 40 + 41 + const mutations = observeMutations(from, () => { 42 + morph(from, to) 43 + }) 44 + 45 + document.body.removeChild(from) 46 + 47 + expect(mutations.childListChanges).toBe(8) 48 + }) 49 + 50 + test("should minimize moves when reordering - complete reversal", () => { 51 + const from = document.createElement("ul") 52 + for (let i = 1; i <= 6; i++) { 53 + from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`)) 54 + } 55 + 56 + const to = document.createElement("ul") 57 + for (let i = 6; i >= 1; i--) { 58 + to.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`)) 59 + } 60 + 61 + document.body.appendChild(from) 62 + 63 + const mutations = observeMutations(from, () => { 64 + morph(from, to) 65 + }) 66 + 67 + document.body.removeChild(from) 68 + 69 + expect(mutations.childListChanges).toBe(10) 70 + }) 71 + 72 + test("should minimize moves when reordering - already optimal", () => { 73 + const from = document.createElement("ul") 74 + for (let i = 1; i <= 5; i++) { 75 + from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`)) 76 + } 77 + 78 + const to = document.createElement("ul") 79 + for (const id of [1, 2, 4, 5, 3]) { 80 + to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`)) 81 + } 82 + 83 + document.body.appendChild(from) 84 + 85 + const mutations = observeMutations(from, () => { 86 + morph(from, to) 87 + }) 88 + 89 + document.body.removeChild(from) 90 + 91 + expect(mutations.childListChanges).toBe(2) 92 + }) 93 + 94 + test("should minimize moves with mixed operations", () => { 95 + const from = document.createElement("ul") 96 + for (let i = 1; i <= 8; i++) { 97 + from.appendChild(dom(`<li id="item-${i}">Item ${i}</li>`)) 98 + } 99 + 100 + const to = document.createElement("ul") 101 + for (const id of [4, 1, 9, 5, 7, 3, 10, 8]) { 102 + to.appendChild(dom(`<li id="item-${id}">Item ${id}</li>`)) 103 + } 104 + 105 + document.body.appendChild(from) 106 + 107 + observeMutations(from, () => { 108 + morph(from, to) 109 + }) 110 + 111 + document.body.removeChild(from) 112 + 113 + expect(from.children.length).toBe(8) 114 + expect(from.children[0]?.id).toBe("item-4") 115 + expect(from.children[7]?.id).toBe("item-8") 116 + }) 117 + })
+84
test/new/utils.ts
··· 1 + export function dom(html: string): HTMLElement { 2 + const tmp = document.createElement("div") 3 + tmp.innerHTML = html.trim() 4 + return tmp.firstChild as HTMLElement 5 + } 6 + 7 + export class Mutations { 8 + records: Array<MutationRecord> = [] 9 + 10 + push(...records: MutationRecord[]) { 11 + this.records.push(...records) 12 + } 13 + 14 + get count(): number { 15 + return this.records.length 16 + } 17 + 18 + get childListChanges(): number { 19 + return this.records.filter((m) => m.type === "childList").length 20 + } 21 + 22 + get elementsAdded(): number { 23 + return this.records.filter( 24 + (m) => m.type === "childList" && Array.from(m.addedNodes).some((n) => n.nodeType === Node.ELEMENT_NODE), 25 + ).length 26 + } 27 + 28 + get elementsRemoved(): number { 29 + return this.records.filter( 30 + (m) => m.type === "childList" && Array.from(m.removedNodes).some((n) => n.nodeType === Node.ELEMENT_NODE), 31 + ).length 32 + } 33 + 34 + get textNodesAdded(): number { 35 + return this.records.filter( 36 + (m) => m.type === "childList" && Array.from(m.addedNodes).some((n) => n.nodeType === Node.TEXT_NODE), 37 + ).length 38 + } 39 + 40 + get textNodesRemoved(): number { 41 + return this.records.filter( 42 + (m) => m.type === "childList" && Array.from(m.removedNodes).some((n) => n.nodeType === Node.TEXT_NODE), 43 + ).length 44 + } 45 + 46 + get nodesAdded(): number { 47 + return this.records.filter((m) => m.type === "childList" && m.addedNodes.length > 0).length 48 + } 49 + 50 + get nodesRemoved(): number { 51 + return this.records.filter((m) => m.type === "childList" && m.removedNodes.length > 0).length 52 + } 53 + 54 + get attributeChanges(): number { 55 + return this.records.filter((m) => m.type === "attributes").length 56 + } 57 + 58 + get characterDataChanges(): number { 59 + return this.records.filter((m) => m.type === "characterData").length 60 + } 61 + } 62 + 63 + export function observeMutations(target: Node, callback: () => void): Mutations { 64 + const mutations = new Mutations() 65 + const observer = new MutationObserver((records) => { 66 + mutations.push(...records) 67 + }) 68 + 69 + observer.observe(target, { 70 + childList: true, 71 + attributes: true, 72 + characterData: true, 73 + subtree: true, 74 + }) 75 + 76 + callback() 77 + 78 + // Flush any pending mutations 79 + const records = observer.takeRecords() 80 + mutations.push(...records) 81 + 82 + observer.disconnect() 83 + return mutations 84 + }
-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 - })
+54
test_debug.js
··· 1 + import { morph } from "./src/morphlex.js" 2 + 3 + function dom(html) { 4 + const tmp = document.createElement("div") 5 + tmp.innerHTML = html.trim() 6 + return tmp.firstChild 7 + } 8 + 9 + const from = dom(` 10 + <ul> 11 + <li>Item 1</li> 12 + <li>Item 2</li> 13 + <li>Item 3</li> 14 + </ul> 15 + `) 16 + 17 + const to = dom(` 18 + <ul> 19 + <li>Item 2</li> 20 + <li>Item 3</li> 21 + </ul> 22 + `) 23 + 24 + console.log("From before:", from.outerHTML) 25 + console.log("To:", to.outerHTML) 26 + 27 + const observer = new MutationObserver((records) => { 28 + console.log("Mutations:", records.length) 29 + records.forEach((r, i) => { 30 + console.log(` ${i}: type=${r.type}`) 31 + if (r.type === "childList") { 32 + console.log(` added: ${r.addedNodes.length}, removed: ${r.removedNodes.length}`) 33 + r.removedNodes.forEach(n => console.log(` removed: ${n.nodeName} ${n.textContent?.trim()}`)) 34 + } 35 + }) 36 + }) 37 + 38 + observer.observe(from, { childList: true, subtree: true }) 39 + 40 + morph(from, to) 41 + 42 + const pending = observer.takeRecords() 43 + console.log("Pending mutations:", pending.length) 44 + pending.forEach((r, i) => { 45 + console.log(` ${i}: type=${r.type}`) 46 + if (r.type === "childList") { 47 + console.log(` added: ${r.addedNodes.length}, removed: ${r.removedNodes.length}`) 48 + r.removedNodes.forEach(n => console.log(` removed: ${n.nodeName} ${n.textContent?.trim()}`)) 49 + } 50 + }) 51 + 52 + observer.disconnect() 53 + 54 + console.log("From after:", from.outerHTML)