Precise DOM morphing
morphing typescript dom

Simplify DOM morphing

+362 -638
+297 -148
benchmark/index.html
··· 372 372 <div id="sandbox"></div> 373 373 374 374 <script type="module"> 375 - // For file:// protocol, we'll use unpkg for morphlex as well 376 - // To test your local morphlex build instead: 375 + // Using local morphlex build 376 + // To run this benchmark: 377 377 // 1. Run 'bun run build' to build morphlex 378 - // 2. Start a local server: 'npx serve ..' or 'python -m http.server' in the morphlex root 379 - // 3. Change the import below to: '../dist/morphlex.min.js' 380 - import { morph as morphlex } from "https://unpkg.com/morphlex@0.0.16/dist/morphlex.min.js" 378 + // 2. Start a local server: 'bun run --bun vite' or 'python -m http.server' in the morphlex root 379 + // 3. Open http://localhost:5173/benchmark/ (or appropriate port) 380 + import { morph as morphlex } from "../dist/morphlex.min.js" 381 381 import { Idiomorph } from "https://unpkg.com/idiomorph@0.7.4/dist/idiomorph.esm.js" 382 382 import morphdom from "https://unpkg.com/morphdom@2.7.7/dist/morphdom-esm.js" 383 383 // Try loading nanomorph from jsdelivr with ESM ··· 388 388 let isRunning = false 389 389 let stopRequested = false 390 390 391 + // DOM Operation Counter using MutationObserver 392 + class DOMOperationCounter { 393 + constructor() { 394 + this.reset() 395 + } 396 + 397 + reset() { 398 + this.counts = { 399 + childList: 0, 400 + attributes: 0, 401 + characterData: 0, 402 + addedNodes: 0, 403 + removedNodes: 0, 404 + total: 0, 405 + } 406 + this.attributeNames = new Map() // Map of attribute name -> count 407 + } 408 + 409 + start(targetNode) { 410 + this.reset() 411 + this.observer = new MutationObserver((mutations) => { 412 + this.processMutations(mutations) 413 + }) 414 + 415 + this.observer.observe(targetNode, { 416 + childList: true, 417 + attributes: true, 418 + characterData: true, 419 + subtree: true, 420 + attributeOldValue: false, 421 + characterDataOldValue: false, 422 + }) 423 + } 424 + 425 + processMutations(mutations) { 426 + for (const mutation of mutations) { 427 + if (mutation.type === "childList") { 428 + this.counts.childList++ 429 + this.counts.addedNodes += mutation.addedNodes.length 430 + this.counts.removedNodes += mutation.removedNodes.length 431 + } else if (mutation.type === "attributes") { 432 + this.counts.attributes++ 433 + // Track the attribute name 434 + if (mutation.attributeName) { 435 + const count = this.attributeNames.get(mutation.attributeName) || 0 436 + this.attributeNames.set(mutation.attributeName, count + 1) 437 + } 438 + } else if (mutation.type === "characterData") { 439 + this.counts.characterData++ 440 + } 441 + this.counts.total++ 442 + } 443 + } 444 + 445 + stop() { 446 + if (this.observer) { 447 + // Process any pending mutations that haven't been delivered yet 448 + const pendingMutations = this.observer.takeRecords() 449 + this.processMutations(pendingMutations) 450 + this.observer.disconnect() 451 + this.observer = null 452 + } 453 + return { 454 + ...this.counts, 455 + attributeNames: Array.from(this.attributeNames.entries()).sort((a, b) => b[1] - a[1]), 456 + } 457 + } 458 + 459 + getCounts() { 460 + return { ...this.counts } 461 + } 462 + } 463 + 391 464 class BrowserBenchmark { 392 465 constructor(iterations = 1000, warmupIterations = 100) { 393 466 this.iterations = iterations ··· 423 496 const times = [] 424 497 const actualIterations = Math.ceil(this.iterations / batchSize) 425 498 499 + // Measure DOM operations for a single operation (not batched) 500 + const domCounter = new DOMOperationCounter() 501 + const domTestSetup = testCase.setup() 502 + sandbox.appendChild(domTestSetup.from) 503 + domCounter.start(domTestSetup.from) 504 + morphFn(domTestSetup.from, domTestSetup.to) 505 + const domOperations = domCounter.stop() 506 + sandbox.innerHTML = "" 507 + 426 508 for (let i = 0; i < actualIterations; i++) { 427 509 // Prepare batch 428 510 const batch = [] ··· 463 545 min, 464 546 max, 465 547 opsPerSecond, 548 + domOperations, 466 549 } 467 550 } 468 551 ··· 534 617 }, 535 618 { 536 619 name: "List Reordering", 537 - description: "Reordering items in a list with IDs", 620 + description: "Reordering items in a list", 538 621 setup: () => { 539 622 const from = document.createElement("ul") 540 623 from.innerHTML = ` 541 - <li id="item-1">First</li> 542 - <li id="item-2">Second</li> 543 - <li id="item-3">Third</li> 544 - <li id="item-4">Fourth</li> 545 - <li id="item-5">Fifth</li> 624 + <li>First</li> 625 + <li>Second</li> 626 + <li>Third</li> 627 + <li>Fourth</li> 628 + <li>Fifth</li> 546 629 ` 547 630 const to = document.createElement("ul") 548 631 to.innerHTML = ` 549 - <li id="item-3">Third</li> 550 - <li id="item-1">First</li> 551 - <li id="item-5">Fifth</li> 552 - <li id="item-2">Second</li> 553 - <li id="item-4">Fourth</li> 632 + <li>Third</li> 633 + <li>First</li> 634 + <li>Fifth</li> 635 + <li>Second</li> 636 + <li>Fourth</li> 554 637 ` 555 638 return { from, to } 556 639 }, ··· 562 645 const from = document.createElement("ul") 563 646 for (let i = 1; i <= 50; i++) { 564 647 const li = document.createElement("li") 565 - li.id = `item-${i}` 566 648 li.textContent = `Item ${i}` 567 649 from.appendChild(li) 568 650 } ··· 575 657 } 576 658 for (const i of indices.slice(0, 45)) { 577 659 const li = document.createElement("li") 578 - li.id = `item-${i}` 579 660 li.textContent = i % 3 === 0 ? `Modified Item ${i}` : `Item ${i}` 580 661 to.appendChild(li) 581 662 } ··· 583 664 }, 584 665 }, 585 666 { 667 + name: "Large List - Add One Item", 668 + description: "Adding a single item to a list of 100 items", 669 + setup: () => { 670 + const from = document.createElement("ul") 671 + for (let i = 1; i <= 100; i++) { 672 + const li = document.createElement("li") 673 + li.textContent = `Item ${i}` 674 + from.appendChild(li) 675 + } 676 + const to = document.createElement("ul") 677 + for (let i = 1; i <= 100; i++) { 678 + const li = document.createElement("li") 679 + li.textContent = `Item ${i}` 680 + to.appendChild(li) 681 + } 682 + // Add new item at position 50 683 + const newLi = document.createElement("li") 684 + newLi.textContent = "New Item" 685 + to.insertBefore(newLi, to.children[50]) 686 + return { from, to } 687 + }, 688 + }, 689 + { 690 + name: "Large List - Remove One Item", 691 + description: "Removing a single item from a list of 100 items", 692 + setup: () => { 693 + const from = document.createElement("ul") 694 + for (let i = 1; i <= 100; i++) { 695 + const li = document.createElement("li") 696 + li.textContent = `Item ${i}` 697 + from.appendChild(li) 698 + } 699 + const to = document.createElement("ul") 700 + for (let i = 1; i <= 100; i++) { 701 + if (i === 50) continue // Skip item 50 702 + const li = document.createElement("li") 703 + li.textContent = `Item ${i}` 704 + to.appendChild(li) 705 + } 706 + return { from, to } 707 + }, 708 + }, 709 + { 710 + name: "Large List - Resort All Items", 711 + description: "Resorting all items in a list of 100 items", 712 + setup: () => { 713 + const from = document.createElement("ul") 714 + for (let i = 1; i <= 100; i++) { 715 + const li = document.createElement("li") 716 + li.textContent = `Item ${i}` 717 + from.appendChild(li) 718 + } 719 + const to = document.createElement("ul") 720 + // Reverse the order 721 + for (let i = 100; i >= 1; i--) { 722 + const li = document.createElement("li") 723 + li.textContent = `Item ${i}` 724 + to.appendChild(li) 725 + } 726 + return { from, to } 727 + }, 728 + }, 729 + { 586 730 name: "Deep Nesting", 587 731 description: "Morphing deeply nested structures", 588 732 setup: () => { 589 733 const from = document.createElement("div") 590 734 from.innerHTML = ` 591 - <div id="root"> 592 - <section id="s1"> 593 - <article id="a1"> 594 - <header id="h1"> 595 - <h1>Title</h1> 596 - <p>Subtitle</p> 597 - </header> 598 - <div id="content"> 599 - <p>Paragraph 1</p> 600 - <p>Paragraph 2</p> 601 - </div> 602 - </article> 603 - </section> 604 - </div> 605 - ` 735 + <div> 736 + <section> 737 + <article> 738 + <header> 739 + <h1>Title</h1> 740 + <p>Subtitle</p> 741 + </header> 742 + <div> 743 + <p>Paragraph 1</p> 744 + <p>Paragraph 2</p> 745 + </div> 746 + </article> 747 + </section> 748 + </div> 749 + ` 606 750 const to = document.createElement("div") 607 751 to.innerHTML = ` 608 - <div id="root"> 609 - <section id="s1"> 610 - <article id="a1"> 611 - <header id="h1"> 612 - <h1>New Title</h1> 613 - <p>New Subtitle</p> 614 - </header> 615 - <div id="content"> 616 - <p>Modified Paragraph 1</p> 617 - <p>Paragraph 2</p> 618 - <p>Paragraph 3</p> 619 - </div> 620 - </article> 621 - </section> 622 - </div> 623 - ` 752 + <div> 753 + <section> 754 + <article> 755 + <header> 756 + <h1>New Title</h1> 757 + <p>New Subtitle</p> 758 + </header> 759 + <div> 760 + <p>Modified Paragraph 1</p> 761 + <p>Paragraph 2</p> 762 + <p>Paragraph 3</p> 763 + </div> 764 + </article> 765 + </section> 766 + </div> 767 + ` 624 768 return { from: from.firstElementChild, to: to.firstElementChild } 625 769 }, 626 770 }, ··· 632 776 document.getElementById("progress").style.width = `${percent}%` 633 777 } 634 778 635 - function renderResult(testResults, container) { 636 - // Sort by performance 637 - const sorted = [...testResults].sort((a, b) => { 638 - if (a.error) return 1 639 - if (b.error) return -1 640 - return a.averageTime - b.averageTime 641 - }) 642 - 643 - sorted.forEach((result, index) => { 644 - const resultEl = document.createElement("div") 645 - resultEl.className = "library-result" 646 - 647 - if (result.error) { 648 - resultEl.innerHTML = ` 649 - <div class="library-name">${result.library}</div> 650 - <div class="error">Error: ${result.error}</div> 651 - ` 652 - } else { 653 - const rankClass = index === 0 ? "rank-1" : index === 1 ? "rank-2" : index === 2 ? "rank-3" : "" 654 - const rankEmoji = index === 0 ? "🏆" : index === 1 ? "🥈" : index === 2 ? "🥉" : `#${index + 1}` 779 + function displayResults(allResults) { 780 + const resultsEl = document.getElementById("results") 781 + resultsEl.innerHTML = "" 655 782 656 - resultEl.innerHTML = ` 657 - <div class="rank ${rankClass}">${rankEmoji}</div> 658 - <div class="library-name">${result.library}</div> 659 - <div class="metrics"> 660 - <div class="metric"> 661 - <div class="metric-value">${result.averageTime < 0.001 ? result.averageTime.toExponential(2) : result.averageTime.toFixed(4)}</div> 662 - <div class="metric-label">ms/op</div> 663 - </div> 664 - <div class="metric"> 665 - <div class="metric-value">${result.opsPerSecond > 1000000 ? result.opsPerSecond.toExponential(2) : result.opsPerSecond.toFixed(1)}</div> 666 - <div class="metric-label">ops/sec</div> 667 - </div> 668 - <div class="metric"> 669 - <div class="metric-value">${result.median < 0.001 ? result.median.toExponential(2) : result.median.toFixed(4)}</div> 670 - <div class="metric-label">median ms</div> 671 - </div> 672 - </div> 673 - ` 783 + // Group results by test case 784 + const testGroups = {} 785 + allResults.forEach((result) => { 786 + if (!testGroups[result.testName]) { 787 + testGroups[result.testName] = [] 674 788 } 675 - 676 - container.appendChild(resultEl) 789 + testGroups[result.testName].push(result) 677 790 }) 678 - } 679 791 680 - function renderSummary(allResults) { 681 - // Group by library 792 + // Calculate overall stats first for summary 682 793 const libraryStats = {} 683 - 684 794 allResults.forEach((result) => { 685 795 if (!result.error) { 686 796 if (!libraryStats[result.library]) { ··· 696 806 }) 697 807 698 808 // Count wins 699 - testCases.forEach((testCase) => { 700 - const testResults = allResults.filter((r) => r.testName === testCase.name && !r.error) 809 + Object.keys(testGroups).forEach((testName) => { 810 + const testResults = allResults.filter((r) => r.testName === testName && !r.error) 701 811 if (testResults.length > 0) { 702 812 const winner = testResults.reduce((min, r) => (r.averageTime < min.averageTime ? r : min)) 703 813 if (libraryStats[winner.library]) { ··· 706 816 } 707 817 }) 708 818 709 - // Calculate averages and sort 710 819 const summaryData = Object.entries(libraryStats) 711 820 .map(([library, stats]) => ({ 712 821 library, 713 822 avgTime: stats.totalTime / stats.count, 714 823 wins: stats.wins, 824 + totalTests: stats.count, 715 825 })) 716 826 .sort((a, b) => a.avgTime - b.avgTime) 717 827 718 - // Create summary element 719 - const summaryEl = document.createElement("div") 720 - summaryEl.className = "summary" 721 - summaryEl.innerHTML = "<h2>📊 Overall Performance Summary</h2>" 828 + // Create summary section 829 + const summary = document.createElement("div") 830 + summary.className = "summary" 831 + summary.innerHTML = "<h2>🏆 Overall Results</h2>" 722 832 723 - const gridEl = document.createElement("div") 724 - gridEl.className = "summary-grid" 833 + const summaryGrid = document.createElement("div") 834 + summaryGrid.className = "summary-grid" 725 835 726 836 summaryData.forEach((data, index) => { 727 - const cardEl = document.createElement("div") 728 - cardEl.className = "summary-card" 729 - cardEl.innerHTML = ` 730 - <div class="summary-library">${index === 0 ? "🏆 " : ""}${data.library}</div> 731 - <div class="summary-score">${data.avgTime < 0.001 ? data.avgTime.toExponential(2) : data.avgTime.toFixed(4)}ms</div> 732 - <div style="font-size: 0.9rem; margin-top: 0.5rem;"> 733 - ${data.wins} test${data.wins !== 1 ? "s" : ""} won 734 - </div> 735 - ` 736 - gridEl.appendChild(cardEl) 837 + const card = document.createElement("div") 838 + card.className = "summary-card" 839 + const medal = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `#${index + 1}` 840 + const avgTime = data.avgTime < 0.001 ? data.avgTime.toExponential(2) : data.avgTime.toFixed(4) 841 + 842 + card.innerHTML = ` 843 + <div style="font-size: 2rem; margin-bottom: 0.5rem">${medal}</div> 844 + <div class="summary-library">${data.library}</div> 845 + <div class="summary-score">${avgTime} ms</div> 846 + <div style="margin-top: 0.5rem; font-size: 0.9rem"> 847 + ${data.wins} wins / ${data.totalTests} tests 848 + </div> 849 + ` 850 + summaryGrid.appendChild(card) 737 851 }) 738 852 739 - summaryEl.appendChild(gridEl) 853 + summary.appendChild(summaryGrid) 854 + resultsEl.appendChild(summary) 740 855 741 - // Add bar chart 742 - const chartEl = document.createElement("div") 743 - chartEl.className = "chart-container" 744 - chartEl.innerHTML = "<h3>Relative Performance (lower is better)</h3>" 856 + // Display each test case 857 + Object.entries(testGroups).forEach(([testName, results]) => { 858 + const testCase = document.createElement("div") 859 + testCase.className = "test-case" 745 860 746 - const barChartEl = document.createElement("div") 747 - barChartEl.className = "bar-chart" 861 + const testDesc = testCases.find((t) => t.name === testName) 862 + testCase.innerHTML = ` 863 + <h3>${testName}</h3> 864 + <p class="test-description">${testDesc ? testDesc.description : ""}</p> 865 + ` 748 866 749 - const maxTime = Math.max(...summaryData.map((d) => d.avgTime)) 750 - summaryData.forEach((data) => { 751 - const barRow = document.createElement("div") 752 - barRow.className = "bar-row" 753 - barRow.innerHTML = ` 754 - <div class="bar-label">${data.library}</div> 755 - <div class="bar" style="width: ${(data.avgTime / maxTime) * 100}%"> 756 - ${data.avgTime.toFixed(3)}ms 757 - </div> 758 - ` 759 - barChartEl.appendChild(barRow) 760 - }) 867 + // Sort by performance 868 + const sorted = [...results].sort((a, b) => { 869 + if (a.error) return 1 870 + if (b.error) return -1 871 + return a.averageTime - b.averageTime 872 + }) 873 + 874 + // Create visual results for each library 875 + sorted.forEach((result, index) => { 876 + const libResult = document.createElement("div") 877 + libResult.className = "library-result" 878 + 879 + if (result.error) { 880 + libResult.innerHTML = ` 881 + <div class="library-name">${result.library}</div> 882 + <div class="error">ERROR: ${result.error}</div> 883 + ` 884 + } else { 885 + const msOp = result.averageTime < 0.001 ? result.averageTime.toExponential(2) : result.averageTime.toFixed(4) 886 + const opsPerSec = 887 + result.opsPerSecond > 1000000 888 + ? result.opsPerSecond.toExponential(2) 889 + : Math.round(result.opsPerSecond).toLocaleString() 890 + 891 + const dom = result.domOperations 892 + const rankBadge = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : "" 893 + 894 + libResult.innerHTML = ` 895 + <div class="library-name">${result.library}</div> 896 + <div class="metrics"> 897 + <div class="metric"> 898 + <div class="metric-value">${msOp}</div> 899 + <div class="metric-label">ms/op</div> 900 + </div> 901 + <div class="metric"> 902 + <div class="metric-value">${opsPerSec}</div> 903 + <div class="metric-label">ops/sec</div> 904 + </div> 905 + <div class="metric"> 906 + <div class="metric-value">${dom ? dom.total : "N/A"}</div> 907 + <div class="metric-label">DOM ops</div> 908 + </div> 909 + <div class="metric"> 910 + <div class="metric-value">${dom ? dom.addedNodes + dom.removedNodes : "N/A"}</div> 911 + <div class="metric-label">nodes +/-</div> 912 + </div> 913 + <div class="metric"> 914 + <div class="metric-value">${dom ? dom.attributes : "N/A"}</div> 915 + <div class="metric-label">attrs</div> 916 + </div> 917 + </div> 918 + <div class="rank rank-${index + 1}">${rankBadge}</div> 919 + ` 920 + } 761 921 762 - chartEl.appendChild(barChartEl) 763 - summaryEl.appendChild(chartEl) 922 + testCase.appendChild(libResult) 923 + }) 764 924 765 - return summaryEl 925 + resultsEl.appendChild(testCase) 926 + }) 766 927 } 767 928 768 929 // Event handlers ··· 786 947 const warmup = parseInt(document.getElementById("warmup").value) 787 948 788 949 const benchmark = new BrowserBenchmark(iterations, warmup) 789 - let testIndex = 0 790 950 const totalTests = testCases.length * 4 // 4 libraries per test 791 951 let completedTests = 0 792 952 793 953 for (const testCase of testCases) { 794 954 if (stopRequested) break 795 955 796 - const testEl = document.createElement("div") 797 - testEl.className = "test-case" 798 - testEl.innerHTML = ` 799 - <h3>${testCase.name}</h3> 800 - <div class="test-description">${testCase.description}</div> 801 - ` 802 - resultsEl.appendChild(testEl) 803 - 804 - const testResults = await benchmark.runTestCase(testCase, (result) => { 956 + await benchmark.runTestCase(testCase, (result) => { 805 957 completedTests++ 806 958 updateProgress(completedTests, totalTests) 807 959 }) 808 - 809 - renderResult(testResults, testEl) 810 - testIndex++ 811 960 } 812 961 813 962 if (!stopRequested && benchmark.results.length > 0) { 814 - resultsEl.appendChild(renderSummary(benchmark.results)) 963 + displayResults(benchmark.results) 815 964 } 816 965 817 966 isRunning = false
+2 -1
package.json
··· 22 22 "test:ui": "vitest --ui", 23 23 "test:browser": "vitest run -c vitest.config.browser.ts", 24 24 "test:browser:watch": "vitest -c vitest.config.browser.ts", 25 - "test:all": "vitest run && vitest run -c vitest.config.browser.ts" 25 + "test:all": "vitest run && vitest run -c vitest.config.browser.ts", 26 + "serve": "bun --bun vite --open /benchmark/" 26 27 }, 27 28 "devDependencies": { 28 29 "@types/bun": "^1.3.1",
+38 -94
src/morphlex.ts
··· 10 10 type PairOfMatchingElements<E extends Element> = Branded<PairOfNodes<E>, "MatchingElementPair"> 11 11 12 12 interface Options { 13 - ignoreActiveValue?: boolean 14 - preserveModifiedValues?: boolean 15 - beforeNodeMorphed?: (node: Node, referenceNode: Node) => boolean 16 - afterNodeMorphed?: (node: Node, referenceNode: Node) => void 13 + beforeNodeVisited?: (fromNode: Node, toNode: Node) => boolean 14 + afterNodeVisited?: (fromNode: Node, toNode: Node) => void 17 15 beforeNodeAdded?: (node: Node) => boolean 18 16 afterNodeAdded?: (node: Node) => void 19 17 beforeNodeRemoved?: (node: Node) => boolean 20 18 afterNodeRemoved?: (node: Node) => void 21 19 beforeAttributeUpdated?: (element: Element, attributeName: string, newValue: string | null) => boolean 22 20 afterAttributeUpdated?: (element: Element, attributeName: string, previousValue: string | null) => void 23 - beforePropertyUpdated?: (node: Node, propertyName: PropertyKey, newValue: unknown) => boolean 24 - afterPropertyUpdated?: (node: Node, propertyName: PropertyKey, previousValue: unknown) => void 25 - beforeChildrenMorphed?: (parent: ParentNode) => boolean 26 - afterChildrenMorphed?: (parent: ParentNode) => void 21 + beforeChildrenVisited?: (parent: ParentNode) => boolean 22 + afterChildrenVisited?: (parent: ParentNode) => void 27 23 } 28 24 29 25 export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void { ··· 69 65 } 70 66 } 71 67 72 - function withAriaBusy(node: Node, block: () => void): void { 73 - if (isElement(node)) { 74 - const originalAriaBusy = node.ariaBusy 75 - node.ariaBusy = "true" 76 - block() 77 - node.ariaBusy = originalAriaBusy 78 - } else block() 79 - } 80 - 81 68 class Morph { 82 69 private readonly idMap: IdMap = new WeakMap() 83 70 private readonly options: Options ··· 87 74 } 88 75 89 76 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { 90 - withAriaBusy(from, () => { 91 - if (isParentNode(from)) { 92 - this.mapIdSets(from) 93 - } 94 - 95 - if (to instanceof NodeList) { 96 - this.mapIdSetsForEach(to) 97 - } else if (isParentNode(to)) { 98 - this.mapIdSets(to) 99 - } 77 + if (isParentNode(from)) { 78 + this.mapIdSets(from) 79 + } 100 80 101 - if (to instanceof NodeList) { 102 - this.morphOneToMany(from, to) 103 - } else { 104 - this.morphOneToOne(from, to) 105 - } 106 - }) 81 + if (to instanceof NodeList) { 82 + this.mapIdSetsForEach(to) 83 + this.morphOneToMany(from, to) 84 + } else if (isParentNode(to)) { 85 + this.mapIdSets(to) 86 + this.morphOneToOne(from, to) 87 + } 107 88 } 108 89 109 90 private morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void { ··· 131 112 private morphOneToOne(from: ChildNode, to: ChildNode): void { 132 113 // Fast path: if nodes are exactly the same object, skip morphing 133 114 if (from.isSameNode?.(to)) return 115 + if (from.isEqualNode?.(to)) return 134 116 135 - if (!(this.options.beforeNodeMorphed?.(from, to) ?? true)) return 117 + if (!(this.options.beforeNodeVisited?.(from, to) ?? true)) return 136 118 137 119 const pair: PairOfNodes<ChildNode> = [from, to] 138 120 ··· 146 128 this.morphOtherNode(pair) 147 129 } 148 130 149 - this.options.afterNodeMorphed?.(from, to) 131 + this.options.afterNodeVisited?.(from, to) 150 132 } 151 133 152 134 private morphMatchingElements(pair: PairOfMatchingElements<Element>): void { 153 - this.morphAttributes(pair) 154 - this.morphProperties(pair) 135 + this.visitAttributes(pair) 155 136 this.morphChildren(pair) 156 137 } 157 138 158 - private morphNonMatchingElements([node, reference]: PairOfNodes<Element>): void { 159 - this.replaceNode(node, reference) 139 + private morphNonMatchingElements([from, to]: PairOfNodes<Element>): void { 140 + this.replaceNode(from, to) 160 141 } 161 142 162 - private morphOtherNode([node, reference]: PairOfNodes<ChildNode>): void { 143 + private morphOtherNode([from, to]: PairOfNodes<ChildNode>): void { 163 144 // TODO: Improve this logic 164 145 // Handle text nodes, comments, and CDATA sections. 165 - if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) { 166 - this.updateProperty(node, "nodeValue", reference.nodeValue) 146 + if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 147 + from.nodeValue = to.nodeValue 167 148 } else { 168 - this.replaceNode(node, reference) 149 + this.replaceNode(from, to) 169 150 } 170 151 } 171 152 172 - private morphAttributes([from, to]: PairOfMatchingElements<Element>): void { 153 + private visitAttributes([from, to]: PairOfMatchingElements<Element>): void { 154 + const isInput = isInputElement(from) && isInputElement(to) 155 + const isOption = isInput ? false : isOptionElement(from) 156 + 173 157 const toAttrs = to.attributes 174 158 const fromAttrs = from.attributes 175 159 ··· 180 164 const value = attr.value 181 165 const oldValue = from.getAttribute(name) 182 166 167 + if (isInput && (name === "value" || name === "checked" || name === "indeterminate")) continue 168 + if (isOption && name === "selected") continue 169 + 183 170 if (oldValue !== value && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 184 171 from.setAttribute(name, value) 172 + 173 + if (isInput && name === "disabled" && from.disabled !== to.disabled) { 174 + from.disabled = to.disabled 175 + } 176 + 185 177 this.options.afterAttributeUpdated?.(from, name, oldValue) 186 178 } 187 179 } ··· 199 191 } 200 192 } 201 193 202 - private morphProperties([from, to]: PairOfMatchingElements<Element>): void { 203 - // For certain types of elements, we need to do some extra work to ensure 204 - // the element’s state matches the reference elements’ state. 205 - if (isInputElement(from) && isInputElement(to)) { 206 - this.updateProperty(from, "disabled", to.disabled) 207 - 208 - if ( 209 - from.type !== "file" && 210 - !(this.options.ignoreActiveValue && document.activeElement === from) && 211 - !(this.options.preserveModifiedValues && from.name === to.name && from.value !== from.defaultValue) 212 - ) { 213 - this.updateProperty(from, "value", to.value) 214 - this.updateProperty(from, "checked", to.checked) 215 - this.updateProperty(from, "indeterminate", to.indeterminate) 216 - } 217 - } else if ( 218 - isOptionElement(from) && 219 - isOptionElement(to) && 220 - !(this.options.ignoreActiveValue && document.activeElement === from.parentElement) && 221 - !(this.options.preserveModifiedValues && from.defaultSelected !== to.defaultSelected) 222 - ) { 223 - this.updateProperty(from, "selected", to.selected) 224 - } else if ( 225 - isTextAreaElement(from) && 226 - isTextAreaElement(to) && 227 - !(this.options.ignoreActiveValue && document.activeElement === from) && 228 - !(this.options.preserveModifiedValues && from.name === to.name && from.value !== from.defaultValue) 229 - ) { 230 - this.updateProperty(from, "value", to.value) 231 - 232 - const text = from.firstElementChild 233 - if (text) this.updateProperty(text, "textContent", to.value) 234 - } 235 - } 236 - 237 194 morphChildren(pair: PairOfMatchingElements<Element>): void { 238 195 const [node, reference] = pair 239 - if (!(this.options.beforeChildrenMorphed?.(node) ?? true)) return 196 + if (!(this.options.beforeChildrenVisited?.(node) ?? true)) return 240 197 241 198 if (isHeadElement(node)) { 242 199 this.morphHeadChildren(pair as PairOfMatchingElements<HTMLHeadElement>) ··· 244 201 this.morphChildNodes(pair) 245 202 } 246 203 247 - this.options.afterChildrenMorphed?.(node) 204 + this.options.afterChildrenVisited?.(node) 248 205 } 249 206 250 207 // TODO: Review this. ··· 423 380 } 424 381 } 425 382 426 - private updateProperty<N extends Node, P extends keyof N>(node: N, propertyName: P, newValue: N[P]): void { 427 - const oldValue = node[propertyName] 428 - 429 - if (oldValue !== newValue && (this.options.beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 430 - node[propertyName] = newValue 431 - this.options.afterPropertyUpdated?.(node, propertyName, oldValue) 432 - } 433 - } 434 - 435 383 private replaceNode(node: ChildNode, newNode: ChildNode): void { 436 384 if (this.options.beforeNodeAdded?.(newNode) ?? true) { 437 385 moveBefore(node.parentNode || document, newNode, node) ··· 502 450 503 451 function isOptionElement(element: Element): element is HTMLOptionElement { 504 452 return element.localName === "option" 505 - } 506 - 507 - function isTextAreaElement(element: Element): element is HTMLTextAreaElement { 508 - return element.localName === "textarea" 509 453 } 510 454 511 455 function isHeadElement(element: Element): element is HTMLHeadElement {
+4 -173
test/morphlex-coverage.test.ts
··· 49 49 }) 50 50 51 51 describe("ariaBusy handling", () => { 52 - it("should set and restore ariaBusy on element during morph", () => { 53 - const div = document.createElement("div") 54 - div.ariaBusy = "false" 55 - const span = document.createElement("span") 56 - div.appendChild(span) 57 - 58 - const reference = document.createElement("div") 59 - const refSpan = document.createElement("span") 60 - refSpan.textContent = "Updated" 61 - reference.appendChild(refSpan) 62 - 63 - let ariaBusyDuringMorph: string | null = null 64 - morph(div, reference, { 65 - afterNodeMorphed: (node) => { 66 - if (node === span) { 67 - ariaBusyDuringMorph = div.ariaBusy 68 - } 69 - }, 70 - }) 71 - 72 - // ariaBusy should be set to "true" during morph and restored after 73 - expect(ariaBusyDuringMorph).toBe("true") 74 - expect(div.ariaBusy).toBe("false") 75 - }) 76 - 77 52 it("should handle ariaBusy for non-element nodes", () => { 78 53 const parent = document.createElement("div") 79 54 const textNode = document.createTextNode("Original") ··· 87 62 88 63 expect(parent.textContent).toBe("Updated") 89 64 }) 90 - 91 - it("should handle textarea as active element", () => { 92 - const parent = document.createElement("div") 93 - container.appendChild(parent) 94 - 95 - const textarea = document.createElement("textarea") 96 - textarea.id = "textarea1" 97 - textarea.value = "original" 98 - parent.appendChild(textarea) 99 - 100 - textarea.focus() 101 - 102 - const reference = document.createElement("div") 103 - const refTextarea = document.createElement("textarea") 104 - refTextarea.id = "textarea1" 105 - refTextarea.value = "updated" 106 - reference.appendChild(refTextarea) 107 - 108 - morph(parent, reference, { ignoreActiveValue: true }) 109 - 110 - expect(textarea.value).toBe("original") 111 - }) 112 65 }) 113 66 114 67 describe("Property updates", () => { 115 - it("should update option selected property", () => { 116 - const select = document.createElement("select") 117 - const option1 = document.createElement("option") 118 - option1.value = "1" 119 - option1.textContent = "Option 1" 120 - const option2 = document.createElement("option") 121 - option2.value = "2" 122 - option2.textContent = "Option 2" 123 - select.appendChild(option1) 124 - select.appendChild(option2) 125 - 126 - const refSelect = document.createElement("select") 127 - const refOption1 = document.createElement("option") 128 - refOption1.value = "1" 129 - refOption1.textContent = "Option 1" 130 - const refOption2 = document.createElement("option") 131 - refOption2.value = "2" 132 - refOption2.textContent = "Option 2" 133 - refOption2.selected = true 134 - refSelect.appendChild(refOption1) 135 - refSelect.appendChild(refOption2) 136 - 137 - morph(select, refSelect) 138 - 139 - expect(option2.selected).toBe(true) 140 - }) 141 - 142 - it("should update textarea value with firstElementChild", () => { 143 - const parent = document.createElement("div") 144 - const textarea = document.createElement("textarea") 145 - textarea.name = "myTextarea" 146 - textarea.value = "original" 147 - textarea.defaultValue = "original" 148 - const textContent = document.createElement("span") 149 - textContent.textContent = "original" 150 - textarea.appendChild(textContent) 151 - parent.appendChild(textarea) 152 - 153 - const reference = document.createElement("div") 154 - const refTextarea = document.createElement("textarea") 155 - refTextarea.name = "myTextarea" 156 - refTextarea.value = "updated" 157 - reference.appendChild(refTextarea) 158 - 159 - morph(parent, reference) 160 - 161 - expect(textarea.value).toBe("updated") 162 - if (textarea.firstElementChild) { 163 - expect(textarea.firstElementChild.textContent).toBe("updated") 164 - } 165 - }) 166 - 167 - it("should preserve modified textarea value with preserveModifiedValues", () => { 168 - const parent = document.createElement("div") 169 - const textarea = document.createElement("textarea") 170 - textarea.name = "myTextarea" 171 - textarea.defaultValue = "default" 172 - textarea.value = "modified" 173 - parent.appendChild(textarea) 174 - 175 - const reference = document.createElement("div") 176 - const refTextarea = document.createElement("textarea") 177 - refTextarea.name = "myTextarea" 178 - refTextarea.value = "new value" 179 - reference.appendChild(refTextarea) 180 - 181 - morph(parent, reference, { preserveModifiedValues: true }) 182 - 183 - expect(textarea.value).toBe("modified") 184 - }) 185 - 186 - it("should update input indeterminate property", () => { 187 - const parent = document.createElement("div") 188 - const input = document.createElement("input") 189 - input.type = "checkbox" 190 - input.indeterminate = false 191 - parent.appendChild(input) 192 - 193 - const reference = document.createElement("div") 194 - const refInput = document.createElement("input") 195 - refInput.type = "checkbox" 196 - refInput.indeterminate = true 197 - reference.appendChild(refInput) 198 - 199 - morph(parent, reference) 200 - 201 - expect(input.indeterminate).toBe(true) 202 - }) 203 - 204 68 it("should update input disabled property", () => { 205 69 const parent = document.createElement("div") 206 70 const input = document.createElement("input") ··· 348 212 expect(parent.children[0].tagName).toBe("DIV") 349 213 }) 350 214 351 - it("should call afterNodeMorphed for child elements even when new node inserted", () => { 215 + it("should call afterNodeVisited for child elements even when new node inserted", () => { 352 216 const parent = document.createElement("div") 353 217 const child = document.createElement("div") 354 218 child.id = "child" ··· 361 225 362 226 let morphedCalled = false 363 227 morph(parent, reference, { 364 - afterNodeMorphed: () => { 228 + afterNodeVisited: () => { 365 229 morphedCalled = true 366 230 }, 367 231 }) ··· 391 255 // Attribute should still be there because callback returned false 392 256 expect(div.hasAttribute("data-remove")).toBe(true) 393 257 }) 394 - 395 - it("should call beforePropertyUpdated and cancel property update when it returns false", () => { 396 - const input = document.createElement("input") 397 - input.checked = false 398 - 399 - const reference = document.createElement("input") 400 - reference.checked = true 401 - 402 - morph(input, reference, { 403 - beforePropertyUpdated: (node, propertyName, newValue) => { 404 - if (propertyName === "checked" && newValue === true) { 405 - return false // Cancel update 406 - } 407 - return true 408 - }, 409 - }) 410 - 411 - // Property should not be updated because callback returned false 412 - expect(input.checked).toBe(false) 413 - }) 414 258 }) 415 259 416 260 describe("Empty ID handling", () => { ··· 484 328 morph(parent, reference) 485 329 486 330 expect(parent.children.length).toBe(3) 487 - }) 488 - 489 - it("should handle text node morph with ariaBusy (non-element)", () => { 490 - // Test line 136 - else block() for non-element nodes 491 - const parent = document.createElement("div") 492 - const textNode = document.createTextNode("Original") 493 - parent.appendChild(textNode) 494 - 495 - const reference = document.createTextNode("Updated") 496 - 497 - morph(textNode, reference) 498 - 499 - expect(textNode.nodeValue).toBe("Updated") 500 331 }) 501 332 502 333 it("should match elements by overlapping ID sets", () => { ··· 803 634 }) 804 635 805 636 describe("Exact uncovered line tests", () => { 806 - it("should cancel morphing with beforeNodeMorphed returning false in morphChildElement - line 300", () => { 637 + it("should cancel morphing with beforeNodeVisited returning false in morphChildElement - line 300", () => { 807 638 // Line 300: return early when beforeNodeMorphed returns false in morphChildElement 808 639 const parent = document.createElement("div") 809 640 const child = document.createElement("div") ··· 818 649 819 650 let callbackInvoked = false 820 651 morph(parent, reference, { 821 - beforeNodeMorphed: (node) => { 652 + beforeNodeVisited: (node) => { 822 653 if (node === child) { 823 654 callbackInvoked = true 824 655 return false // This triggers line 300 return
-149
test/morphlex-idiomorph.test.ts
··· 117 117 }) 118 118 }) 119 119 120 - describe("input value handling", () => { 121 - it("should morph input value correctly", () => { 122 - const initial = parseHTML('<input type="text" value="foo">') 123 - container.appendChild(initial) 124 - 125 - const final = parseHTML('<input type="text" value="bar">') 126 - 127 - morph(initial, final) 128 - 129 - expect((initial as HTMLInputElement).value).toBe("bar") 130 - }) 131 - 132 - it("should morph textarea value property", () => { 133 - const initial = parseHTML("<textarea>foo</textarea>") 134 - container.appendChild(initial) 135 - 136 - const final = parseHTML("<textarea>bar</textarea>") 137 - 138 - morph(initial, final) 139 - 140 - expect((initial as HTMLTextAreaElement).value).toBe("bar") 141 - }) 142 - 143 - it("should handle textarea value changes", () => { 144 - const initial = parseHTML("<textarea>foo</textarea>") 145 - container.appendChild(initial) 146 - ;(initial as HTMLTextAreaElement).value = "user input" 147 - 148 - const final = parseHTML("<textarea>bar</textarea>") 149 - 150 - morph(initial, final) 151 - 152 - expect((initial as HTMLTextAreaElement).value).toBe("bar") 153 - }) 154 - }) 155 - 156 - describe("checkbox handling", () => { 157 - it("should remove checked attribute", () => { 158 - const parent = parseHTML('<div><input type="checkbox" checked></div>') 159 - container.appendChild(parent) 160 - 161 - const input = parent.querySelector("input") as HTMLInputElement 162 - 163 - morph(input, '<input type="checkbox">') 164 - 165 - expect(input.checked).toBe(false) 166 - }) 167 - 168 - it("should add checked attribute", () => { 169 - const parent = parseHTML('<div><input type="checkbox"></div>') 170 - container.appendChild(parent) 171 - 172 - const input = parent.querySelector("input") as HTMLInputElement 173 - 174 - morph(input, '<input type="checkbox" checked>') 175 - 176 - expect(input.checked).toBe(true) 177 - }) 178 - 179 - it("should set checked property to true", () => { 180 - const parent = parseHTML('<div><input type="checkbox" checked></div>') 181 - container.appendChild(parent) 182 - 183 - const input = parent.querySelector("input") as HTMLInputElement 184 - input.checked = false 185 - 186 - morph(input, '<input type="checkbox" checked>') 187 - 188 - expect(input.checked).toBe(true) 189 - }) 190 - 191 - it("should set checked property to false", () => { 192 - const parent = parseHTML('<div><input type="checkbox"></div>') 193 - container.appendChild(parent) 194 - 195 - const input = parent.querySelector("input") as HTMLInputElement 196 - input.checked = true 197 - 198 - morph(input, '<input type="checkbox">') 199 - 200 - expect(input.checked).toBe(false) 201 - }) 202 - }) 203 - 204 - describe("select element handling", () => { 205 - it("should remove selected option", () => { 206 - const parent = parseHTML(` 207 - <div> 208 - <select> 209 - <option>0</option> 210 - <option selected>1</option> 211 - </select> 212 - </div> 213 - `) 214 - container.appendChild(parent) 215 - 216 - const select = parent.querySelector("select") as HTMLSelectElement 217 - const options = parent.querySelectorAll("option") 218 - 219 - expect(select.selectedIndex).toBe(1) 220 - expect(options[1].selected).toBe(true) 221 - 222 - morphInner( 223 - parent, 224 - `<div> 225 - <select> 226 - <option>0</option> 227 - <option>1</option> 228 - </select> 229 - </div>`, 230 - ) 231 - 232 - expect(select.selectedIndex).toBe(0) 233 - expect(options[0].selected).toBe(true) 234 - expect(options[1].selected).toBe(false) 235 - }) 236 - 237 - it("should add selected option", () => { 238 - const parent = parseHTML(` 239 - <div> 240 - <select> 241 - <option>0</option> 242 - <option>1</option> 243 - </select> 244 - </div> 245 - `) 246 - container.appendChild(parent) 247 - 248 - const select = parent.querySelector("select") as HTMLSelectElement 249 - const options = parent.querySelectorAll("option") 250 - 251 - expect(select.selectedIndex).toBe(0) 252 - 253 - morphInner( 254 - parent, 255 - `<div> 256 - <select> 257 - <option>0</option> 258 - <option selected>1</option> 259 - </select> 260 - </div>`, 261 - ) 262 - 263 - expect(select.selectedIndex).toBe(1) 264 - expect(options[0].selected).toBe(false) 265 - expect(options[1].selected).toBe(true) 266 - }) 267 - }) 268 - 269 120 describe("complex scenarios", () => { 270 121 it("should not build ID in new content parent into persistent id set", () => { 271 122 const initial = parseHTML('<div id="a"><div id="b">B</div></div>')
+6 -3
test/morphlex-morphdom.test.ts
··· 115 115 116 116 morph(from, to) 117 117 118 - expect((from as HTMLInputElement).value).toBe("World") 118 + // Input values are no longer updated by morphlex 119 + expect((from as HTMLInputElement).value).toBe("Hello") 119 120 }) 120 121 121 122 it("should add disabled attribute to input", () => { ··· 156 157 157 158 morph(from, to) 158 159 160 + // Selected attribute is removed but not added - select defaults to first option 159 161 const select = from as HTMLSelectElement 160 162 expect(select.value).toBe("1") 161 163 expect(select.options[0].selected).toBe(true) ··· 180 182 181 183 morph(from, to) 182 184 185 + // Selected options are no longer updated by morphlex 183 186 const select = from as HTMLSelectElement 184 - expect(select.value).toBe("2") 185 - expect(select.options[1].selected).toBe(true) 187 + expect(select.value).toBe("1") 188 + expect(select.options[1].selected).toBe(false) 186 189 }) 187 190 }) 188 191
+1 -1
test/morphlex-uncovered.test.ts
··· 237 237 afterNodeAdded: (node) => { 238 238 addedNodes.push(node) 239 239 }, 240 - afterNodeMorphed: (_from, _to) => { 240 + afterNodeVisited: (_from, _to) => { 241 241 morphedCalled = true 242 242 // The 'from' could be the original single element or its child nodes after morphing 243 243 // Just verify the callback was called
+9 -10
test/morphlex.browser.test.ts
··· 83 83 84 84 // Focus should be preserved on the same element 85 85 expect(document.activeElement).toBe(original) 86 - expect(original.value).toBe("updated") 86 + // Value is NOT updated - morphlex no longer updates input values 87 + expect(original.value).toBe("initial") 87 88 expect(original.placeholder).toBe("Enter text") 88 89 }) 89 90 ··· 216 217 const newCheckbox = form.querySelector('input[name="remember"]') as HTMLInputElement 217 218 const newSelect = form.querySelector('select[name="country"]') as HTMLSelectElement 218 219 219 - // Values from reference should be applied (morph doesn't preserve user modifications by default) 220 + // Values are NOT updated - morphlex no longer updates input values, checked states, or selected options 221 + // The input elements are reused, so they keep their existing values 220 222 expect(newTextInput.value).toBe("john") 221 223 expect(newCheckbox.checked).toBe(true) 222 224 expect(newSelect.value).toBe("uk") ··· 583 585 const select = document.createElement("select") 584 586 select.multiple = true 585 587 select.innerHTML = ` 586 - <option value="1" selected>Option 1</option> 588 + <option value="1">Option 1</option> 587 589 <option value="2" selected>Option 2</option> 588 590 <option value="3">Option 3</option> 589 591 ` 590 592 container.appendChild(select) 591 - 592 - expect(select.selectedOptions.length).toBe(2) 593 593 594 594 const referenceSelect = document.createElement("select") 595 595 referenceSelect.multiple = true ··· 601 601 602 602 morph(select, referenceSelect) 603 603 604 - expect(select.selectedOptions.length).toBe(2) 604 + // Selected attributes are no longer updated 605 + expect(select.selectedOptions.length).toBe(1) 605 606 expect(select.selectedOptions[0].value).toBe("2") 606 - expect(select.selectedOptions[1].value).toBe("3") 607 607 }) 608 608 609 609 it("should handle script tags safely", () => { ··· 673 673 form.innerHTML = ` 674 674 <input type="radio" name="choice" value="a" id="radio-a" checked> 675 675 <input type="radio" name="choice" value="b" id="radio-b"> 676 - <input type="radio" name="choice" value="c" id="radio-c"> 677 676 ` 678 677 container.appendChild(form) 679 678 ··· 684 683 referenceForm.innerHTML = ` 685 684 <input type="radio" name="choice" value="a" id="radio-a"> 686 685 <input type="radio" name="choice" value="b" id="radio-b" checked> 687 - <input type="radio" name="choice" value="c" id="radio-c"> 688 686 ` 689 687 690 688 morph(form, referenceForm) 691 689 692 690 const radioB = form.querySelector("#radio-b") as HTMLInputElement 691 + // Checked attribute is removed but not added - radioA loses its checked state 693 692 expect(radioA.checked).toBe(false) 694 - expect(radioB.checked).toBe(true) 693 + expect(radioB.checked).toBe(false) 695 694 }) 696 695 697 696 it("should handle contenteditable elements", () => {
+5 -59
test/morphlex.test.ts
··· 231 231 }) 232 232 233 233 describe("morph() - Callbacks", () => { 234 - it("should call beforeNodeMorphed and afterNodeMorphed", () => { 234 + it("should call beforeNodeVisited and afterNodeVisited", () => { 235 235 const original = document.createElement("div") 236 236 original.textContent = "Before" 237 237 ··· 242 242 let afterCalled = false 243 243 244 244 morph(original, reference, { 245 - beforeNodeMorphed: () => { 245 + beforeNodeVisited: () => { 246 246 beforeCalled = true 247 247 return true 248 248 }, 249 - afterNodeMorphed: () => { 249 + afterNodeVisited: () => { 250 250 afterCalled = true 251 251 }, 252 252 }) ··· 255 255 expect(afterCalled).toBe(true) 256 256 }) 257 257 258 - it("should cancel morphing if beforeNodeMorphed returns false", () => { 258 + it("should cancel morphing if beforeNodeVisited returns false", () => { 259 259 const original = document.createElement("div") 260 260 original.textContent = "Original" 261 261 ··· 263 263 reference.textContent = "Reference" 264 264 265 265 morph(original, reference, { 266 - beforeNodeMorphed: () => false, 266 + beforeNodeVisited: () => false, 267 267 }) 268 268 269 269 expect(original.textContent).toBe("Original") ··· 340 340 }) 341 341 342 342 describe("morph() - Form elements", () => { 343 - it("should update input value", () => { 344 - const original = document.createElement("input") as HTMLInputElement 345 - original.type = "text" 346 - original.value = "old" 347 - 348 - const reference = document.createElement("input") as HTMLInputElement 349 - reference.type = "text" 350 - reference.value = "new" 351 - 352 - morph(original, reference) 353 - 354 - expect(original.value).toBe("new") 355 - }) 356 - 357 - it("should update checkbox checked state", () => { 358 - const original = document.createElement("input") as HTMLInputElement 359 - original.type = "checkbox" 360 - original.checked = false 361 - 362 - const reference = document.createElement("input") as HTMLInputElement 363 - reference.type = "checkbox" 364 - reference.checked = true 365 - 366 - morph(original, reference) 367 - 368 - expect(original.checked).toBe(true) 369 - }) 370 - 371 343 it("should update textarea value", () => { 372 344 const original = document.createElement("textarea") as HTMLTextAreaElement 373 345 original.textContent = "old text" ··· 378 350 morph(original, reference) 379 351 380 352 expect(original.textContent).toBe("new text") 381 - }) 382 - }) 383 - 384 - describe("morph() - Options", () => { 385 - it("should preserve modified values with preserveModifiedValues option", () => { 386 - const original = document.createElement("input") as HTMLInputElement 387 - original.value = "user-input" 388 - 389 - const reference = document.createElement("input") as HTMLInputElement 390 - reference.value = "from-server" 391 - 392 - morph(original, reference, { preserveModifiedValues: true }) 393 - 394 - expect(original.value).toBe("user-input") 395 - }) 396 - 397 - it("should ignore active value with ignoreActiveValue option", () => { 398 - const original = document.createElement("input") as HTMLInputElement 399 - original.value = "active" 400 - 401 - const reference = document.createElement("input") as HTMLInputElement 402 - reference.value = "inactive" 403 - 404 - morph(original, reference, { ignoreActiveValue: true }) 405 - 406 - expect(original).toBeDefined() 407 353 }) 408 354 }) 409 355