Precise DOM morphing
morphing typescript dom

remove comparison benchmarks

+216 -1616
+216 -865
benchmark/index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Morphlex Benchmark Suite</title> 6 + <title>Morphlex Benchmark</title> 7 7 <style> 8 - :root { 9 - --primary: #6366f1; 10 - --success: #10b981; 11 - --warning: #f59e0b; 12 - --danger: #ef4444; 13 - --dark: #1f2937; 14 - --light: #f3f4f6; 15 - --white: #ffffff; 16 - } 17 - 18 8 * { 19 9 margin: 0; 20 10 padding: 0; ··· 25 15 font-family: 26 16 system-ui, 27 17 -apple-system, 28 - BlinkMacSystemFont, 29 - "Segoe UI", 30 - Roboto, 31 - Oxygen, 32 - Ubuntu, 33 18 sans-serif; 34 19 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 35 20 min-height: 100vh; 36 21 padding: 2rem; 22 + color: #1f2937; 37 23 } 38 24 39 25 .container { 40 - max-width: 1200px; 26 + max-width: 900px; 41 27 margin: 0 auto; 42 28 } 43 29 44 30 header { 45 31 text-align: center; 46 - color: var(--white); 32 + color: white; 47 33 margin-bottom: 2rem; 48 34 } 49 35 50 36 h1 { 51 - font-size: 3rem; 37 + font-size: 2.5rem; 52 38 margin-bottom: 0.5rem; 53 39 text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); 54 40 } 55 41 56 42 .subtitle { 57 - font-size: 1.2rem; 58 - opacity: 0.95; 43 + font-size: 1.1rem; 44 + opacity: 0.9; 59 45 } 60 46 61 - .controls { 62 - background: var(--white); 47 + .card { 48 + background: white; 63 49 border-radius: 12px; 64 - padding: 1.5rem; 65 - margin-bottom: 2rem; 50 + padding: 2rem; 51 + margin-bottom: 1.5rem; 66 52 box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); 67 53 } 68 54 69 - .control-group { 55 + .controls { 70 56 display: flex; 71 57 gap: 1rem; 58 + flex-wrap: wrap; 72 59 align-items: center; 73 - flex-wrap: wrap; 60 + margin-bottom: 1.5rem; 74 61 } 75 62 76 63 label { 77 64 font-weight: 600; 78 - color: var(--dark); 65 + display: flex; 66 + align-items: center; 67 + gap: 0.5rem; 79 68 } 80 69 81 - input[type="number"] { 70 + input[type="number"], 71 + select { 82 72 padding: 0.5rem; 83 - border: 2px solid var(--light); 73 + border: 2px solid #e5e7eb; 84 74 border-radius: 6px; 85 75 font-size: 1rem; 86 - width: 120px; 76 + } 77 + 78 + input[type="number"] { 79 + width: 100px; 80 + } 81 + 82 + select { 83 + min-width: 200px; 87 84 } 88 85 89 86 button { 90 87 padding: 0.75rem 2rem; 91 - background: var(--primary); 92 - color: var(--white); 88 + background: #6366f1; 89 + color: white; 93 90 border: none; 94 91 border-radius: 6px; 95 92 font-size: 1rem; 96 93 font-weight: 600; 97 94 cursor: pointer; 98 - transition: 99 - transform 0.2s, 100 - box-shadow 0.2s; 95 + transition: all 0.2s; 101 96 } 102 97 103 - button:hover:not(:disabled) { 104 - transform: translateY(-2px); 105 - box-shadow: 0 5px 15px rgba(99, 102, 241, 0.4); 98 + button:hover { 99 + background: #4f46e5; 100 + transform: translateY(-1px); 106 101 } 107 102 108 103 button:disabled { 109 - background: var(--light); 110 - color: #9ca3af; 104 + background: #9ca3af; 111 105 cursor: not-allowed; 112 - } 113 - 114 - button.stop { 115 - background: var(--danger); 116 - } 117 - 118 - .status { 119 - margin-left: auto; 120 - padding: 0.5rem 1rem; 121 - border-radius: 20px; 122 - background: var(--light); 123 - font-weight: 600; 124 - color: var(--dark); 125 - } 126 - 127 - .status.running { 128 - background: var(--warning); 129 - color: var(--white); 130 - animation: pulse 1s infinite; 131 - } 132 - 133 - .status.complete { 134 - background: var(--success); 135 - color: var(--white); 136 - } 137 - 138 - @keyframes pulse { 139 - 0%, 140 - 100% { 141 - opacity: 1; 142 - } 143 - 50% { 144 - opacity: 0.7; 145 - } 106 + transform: none; 146 107 } 147 108 148 109 .results { 149 - background: var(--white); 150 - border-radius: 12px; 151 - padding: 1.5rem; 152 - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); 110 + display: none; 153 111 } 154 112 155 - .test-case { 156 - margin-bottom: 2rem; 157 - padding: 1.5rem; 158 - background: var(--light); 159 - border-radius: 8px; 113 + .results.visible { 114 + display: block; 160 115 } 161 116 162 - .test-case h3 { 163 - color: var(--primary); 164 - margin-bottom: 0.5rem; 117 + .result-item { 118 + padding: 1rem; 119 + border-bottom: 1px solid #e5e7eb; 165 120 } 166 121 167 - .test-description { 168 - color: #6b7280; 169 - margin-bottom: 1rem; 122 + .result-item:last-child { 123 + border-bottom: none; 170 124 } 171 125 172 - .library-result { 126 + .result-header { 173 127 display: flex; 174 128 justify-content: space-between; 175 129 align-items: center; 176 - padding: 0.75rem; 177 - margin: 0.5rem 0; 178 - background: var(--white); 179 - border-radius: 6px; 180 - transition: transform 0.2s; 130 + margin-bottom: 0.5rem; 181 131 } 182 132 183 - .library-result:hover { 184 - transform: translateX(5px); 185 - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 186 - } 187 - 188 - .library-name { 133 + .result-name { 189 134 font-weight: 600; 190 - min-width: 150px; 135 + font-size: 1.1rem; 191 136 } 192 137 193 - .metrics { 194 - display: flex; 195 - gap: 2rem; 196 - flex: 1; 197 - justify-content: center; 138 + .result-time { 139 + font-size: 1.5rem; 140 + font-weight: 700; 141 + color: #6366f1; 198 142 } 199 143 200 - .metric { 201 - text-align: center; 144 + .result-details { 145 + display: grid; 146 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 147 + gap: 1rem; 148 + margin-top: 0.75rem; 202 149 } 203 150 204 - .metric-value { 205 - font-size: 1.2rem; 206 - font-weight: 700; 207 - color: var(--primary); 151 + .detail { 152 + display: flex; 153 + flex-direction: column; 208 154 } 209 155 210 - .metric-label { 211 - font-size: 0.8rem; 212 - color: #9ca3af; 156 + .detail-label { 157 + font-size: 0.75rem; 158 + color: #6b7280; 213 159 text-transform: uppercase; 214 160 letter-spacing: 0.5px; 215 161 } 216 162 217 - .rank { 218 - min-width: 50px; 219 - text-align: center; 220 - font-weight: 700; 221 - } 222 - 223 - .rank-1 { 224 - color: #fbbf24; 225 - font-size: 1.5rem; 163 + .detail-value { 164 + font-weight: 600; 165 + font-size: 1rem; 226 166 } 227 167 228 - .rank-2 { 229 - color: #9ca3af; 230 - font-size: 1.3rem; 231 - } 232 - 233 - .rank-3 { 234 - color: #cd7f32; 235 - font-size: 1.2rem; 236 - } 237 - 238 - .summary { 239 - margin-top: 2rem; 240 - padding: 2rem; 241 - background: linear-gradient(135deg, var(--primary), #8b5cf6); 242 - border-radius: 12px; 243 - color: var(--white); 244 - } 245 - 246 - .summary h2 { 247 - margin-bottom: 1.5rem; 168 + .progress { 169 + margin-top: 0.75rem; 248 170 text-align: center; 249 - } 250 - 251 - .summary-grid { 252 - display: grid; 253 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 254 - gap: 1.5rem; 255 - } 256 - 257 - .summary-card { 258 - background: rgba(255, 255, 255, 0.15); 259 - backdrop-filter: blur(10px); 260 - padding: 1.5rem; 261 - border-radius: 8px; 262 - text-align: center; 263 - } 264 - 265 - .summary-library { 266 - font-size: 1.2rem; 267 - font-weight: 700; 268 - margin-bottom: 0.5rem; 269 - } 270 - 271 - .summary-score { 272 - font-size: 2rem; 273 - font-weight: 900; 274 - } 275 - 276 - .progress-bar { 277 - width: 100%; 278 - height: 6px; 279 - background: var(--light); 280 - border-radius: 3px; 281 - overflow: hidden; 282 - margin-top: 1rem; 283 - } 284 - 285 - .progress-fill { 286 - height: 100%; 287 - background: var(--success); 288 - border-radius: 3px; 289 - transition: width 0.3s ease; 171 + color: #6b7280; 172 + font-style: italic; 290 173 } 291 174 292 175 #sandbox { 293 176 display: none; 294 177 } 295 - 296 - .error { 297 - background: #fee; 298 - border: 2px solid var(--danger); 299 - color: var(--danger); 300 - padding: 1rem; 301 - border-radius: 8px; 302 - margin: 1rem 0; 303 - } 304 - 305 - .chart-container { 306 - margin: 2rem 0; 307 - padding: 1.5rem; 308 - background: var(--white); 309 - border-radius: 8px; 310 - } 311 - 312 - .bar-chart { 313 - display: flex; 314 - flex-direction: column; 315 - gap: 0.5rem; 316 - } 317 - 318 - .bar-row { 319 - display: flex; 320 - align-items: center; 321 - gap: 1rem; 322 - } 323 - 324 - .bar-label { 325 - min-width: 120px; 326 - font-weight: 600; 327 - } 328 - 329 - .bar { 330 - height: 30px; 331 - background: linear-gradient(90deg, var(--primary), #8b5cf6); 332 - border-radius: 4px; 333 - display: flex; 334 - align-items: center; 335 - padding: 0 0.5rem; 336 - color: var(--white); 337 - font-weight: 600; 338 - font-size: 0.9rem; 339 - } 340 178 </style> 341 179 </head> 342 180 <body> 343 181 <div class="container"> 344 182 <header> 345 - <h1>⚡ Morphlex Benchmark Suite</h1> 346 - <p class="subtitle">Performance comparison of DOM morphing libraries</p> 183 + <h1>Morphlex Benchmark</h1> 184 + <p class="subtitle">Performance testing for DOM morphing</p> 347 185 </header> 348 186 349 - <div class="controls"> 350 - <div class="control-group"> 351 - <label for="iterations">Iterations:</label> 352 - <input type="number" id="iterations" value="5000" min="1000" max="50000" step="1000" /> 353 - 354 - <label for="warmup">Warmup:</label> 355 - <input type="number" id="warmup" value="500" min="100" max="5000" step="100" /> 356 - 357 - <button id="runBtn">🚀 Run Benchmark</button> 358 - <button id="stopBtn" class="stop" style="display: none">⏹ Stop</button> 359 - 360 - <div class="status" id="status">Ready</div> 361 - </div> 362 - <div class="progress-bar"> 363 - <div class="progress-fill" id="progress" style="width: 0%"></div> 187 + <div class="card"> 188 + <div class="controls"> 189 + <label> 190 + Benchmark: 191 + <select id="benchmarkSelect"> 192 + <option value="all">All Benchmarks</option> 193 + </select> 194 + </label> 195 + <label> 196 + Iterations: 197 + <input type="number" id="iterations" value="1000" min="100" step="100" /> 198 + </label> 199 + <button id="runBtn">Run Benchmark</button> 364 200 </div> 201 + <div class="progress" id="progress"></div> 365 202 </div> 366 203 367 - <div class="results" id="results"> 368 - <p style="text-align: center; color: #9ca3af; padding: 2rem">Click "Run Benchmark" to start testing...</p> 204 + <div class="card results" id="results"> 205 + <h2 style="margin-bottom: 1.5rem">Results</h2> 206 + <div id="resultsContainer"></div> 369 207 </div> 370 208 </div> 371 209 372 210 <div id="sandbox"></div> 373 211 374 212 <script type="module"> 375 - // Using local morphlex build 376 - // To run this benchmark: 377 - // 1. Run 'bun run build' to build morphlex 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 - import { morph as dataMorph } from "../dist/data-morph.min.js" 382 - import { Idiomorph } from "https://unpkg.com/idiomorph@0.7.4/dist/idiomorph.esm.js" 383 - import morphdom from "https://unpkg.com/morphdom@2.7.7/dist/morphdom-esm.js" 384 - // Try loading nanomorph from jsdelivr with ESM 385 - import nanomorph from "https://cdn.jsdelivr.net/npm/nanomorph@5.4.3/+esm" 386 - // Load Alpine.js and Alpine morph 387 - import Alpine from "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/module.esm.js" 388 - import Morph from "https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.x.x/dist/module.esm.js" 389 - 390 - // Initialize Alpine with morph plugin 391 - Alpine.plugin(Morph) 392 - window.Alpine = Alpine 393 - Alpine.start() 394 - 395 - const sandbox = document.getElementById("sandbox") 396 - let isRunning = false 397 - let stopRequested = false 398 - 399 - // DOM Operation Counter using MutationObserver 400 - class DOMOperationCounter { 401 - constructor() { 402 - this.reset() 403 - } 404 - 405 - reset() { 406 - this.counts = { 407 - childList: 0, 408 - attributes: 0, 409 - characterData: 0, 410 - addedNodes: 0, 411 - removedNodes: 0, 412 - total: 0, 413 - } 414 - this.attributeNames = new Map() // Map of attribute name -> count 415 - } 416 - 417 - start(targetNode) { 418 - this.reset() 419 - this.observer = new MutationObserver((mutations) => { 420 - this.processMutations(mutations) 421 - }) 422 - 423 - this.observer.observe(targetNode, { 424 - childList: true, 425 - attributes: true, 426 - characterData: true, 427 - subtree: true, 428 - attributeOldValue: false, 429 - characterDataOldValue: false, 430 - }) 431 - } 432 - 433 - processMutations(mutations) { 434 - for (const mutation of mutations) { 435 - if (mutation.type === "childList") { 436 - this.counts.childList++ 437 - this.counts.addedNodes += mutation.addedNodes.length 438 - this.counts.removedNodes += mutation.removedNodes.length 439 - } else if (mutation.type === "attributes") { 440 - this.counts.attributes++ 441 - // Track the attribute name 442 - if (mutation.attributeName) { 443 - const count = this.attributeNames.get(mutation.attributeName) || 0 444 - this.attributeNames.set(mutation.attributeName, count + 1) 445 - } 446 - } else if (mutation.type === "characterData") { 447 - this.counts.characterData++ 448 - } 449 - this.counts.total++ 450 - } 451 - } 452 - 453 - stop() { 454 - if (this.observer) { 455 - // Process any pending mutations that haven't been delivered yet 456 - const pendingMutations = this.observer.takeRecords() 457 - this.processMutations(pendingMutations) 458 - this.observer.disconnect() 459 - this.observer = null 460 - } 461 - return { 462 - ...this.counts, 463 - attributeNames: Array.from(this.attributeNames.entries()).sort((a, b) => b[1] - a[1]), 464 - } 465 - } 466 - 467 - getCounts() { 468 - return { ...this.counts } 469 - } 470 - } 471 - 472 - class BrowserBenchmark { 473 - constructor(iterations = 1000, warmupIterations = 100) { 474 - this.iterations = iterations 475 - this.warmupIterations = warmupIterations 476 - this.results = [] 477 - } 478 - 479 - async runSingleBenchmark(library, testCase, morphFn) { 480 - // Warmup 481 - for (let i = 0; i < this.warmupIterations; i++) { 482 - const { from, to } = testCase.setup() 483 - sandbox.appendChild(from) 484 - morphFn(from, to) 485 - sandbox.innerHTML = "" 486 - } 487 - 488 - // Allow browser to settle 489 - await new Promise((resolve) => setTimeout(resolve, 10)) 490 - 491 - // First, do a quick test to see how fast the operation is 492 - const testRun = testCase.setup() 493 - sandbox.appendChild(testRun.from) 494 - const testStart = performance.now() 495 - morphFn(testRun.from, testRun.to) 496 - const testEnd = performance.now() 497 - sandbox.innerHTML = "" 498 - const singleOpTime = testEnd - testStart 213 + import { morph } from "../dist/morphlex.js" 499 214 500 - // If operation is very fast (< 0.1ms), batch multiple operations per measurement 501 - const batchSize = singleOpTime < 0.1 ? Math.min(100, Math.ceil(0.1 / Math.max(singleOpTime, 0.001))) : 1 502 - 503 - // Actual benchmark - use higher precision timing with batching 504 - const times = [] 505 - const actualIterations = Math.ceil(this.iterations / batchSize) 506 - 507 - // Measure DOM operations for a single operation (not batched) 508 - const domCounter = new DOMOperationCounter() 509 - const domTestSetup = testCase.setup() 510 - sandbox.appendChild(domTestSetup.from) 511 - domCounter.start(domTestSetup.from) 512 - morphFn(domTestSetup.from, domTestSetup.to) 513 - const domOperations = domCounter.stop() 514 - sandbox.innerHTML = "" 515 - 516 - for (let i = 0; i < actualIterations; i++) { 517 - // Prepare batch 518 - const batch = [] 519 - for (let j = 0; j < batchSize; j++) { 520 - batch.push(testCase.setup()) 521 - } 522 - 523 - // Measure batch execution 524 - const start = performance.now() 525 - for (let j = 0; j < batchSize; j++) { 526 - sandbox.appendChild(batch[j].from) 527 - morphFn(batch[j].from, batch[j].to) 528 - sandbox.innerHTML = "" 529 - } 530 - const end = performance.now() 531 - 532 - const elapsed = (end - start) / batchSize // Average per operation 533 - times.push(elapsed) 534 - } 535 - 536 - // Calculate statistics with better precision 537 - const totalTime = times.reduce((a, b) => a + b, 0) 538 - const averageTime = totalTime / times.length 539 - const sorted = [...times].sort((a, b) => a - b) 540 - const median = sorted[Math.floor(sorted.length / 2)] 541 - const min = sorted[0] 542 - const max = sorted[sorted.length - 1] 543 - // Prevent infinity and provide more precision 544 - const opsPerSecond = averageTime > 0 ? 1000 / averageTime : 0 545 - 546 - return { 547 - library, 548 - testName: testCase.name, 549 - iterations: actualIterations * batchSize, 550 - totalTime: totalTime, 551 - averageTime, 552 - median, 553 - min, 554 - max, 555 - opsPerSecond, 556 - domOperations, 557 - } 558 - } 559 - 560 - async runTestCase(testCase, onProgress) { 561 - const results = [] 562 - const libraries = [ 563 - { name: "morphlex", fn: (from, to) => morphlex(from, to) }, 564 - { name: "data-morph", fn: (from, to) => dataMorph(from, to.cloneNode(true), "outer") }, 565 - { name: "idiomorph", fn: (from, to) => Idiomorph.morph(from, to) }, 566 - { name: "morphdom", fn: (from, to) => morphdom(from, to.cloneNode(true)) }, 567 - { name: "nanomorph", fn: (from, to) => nanomorph(from, to.cloneNode(true)) }, 568 - { name: "alpine-morph", fn: (from, to) => Alpine.morph(from, to) }, 569 - ] 570 - 571 - for (const lib of libraries) { 572 - if (stopRequested) break 573 - 574 - try { 575 - const result = await this.runSingleBenchmark(lib.name, testCase, lib.fn) 576 - results.push(result) 577 - this.results.push(result) 578 - if (onProgress) onProgress(result) 579 - } catch (error) { 580 - console.error(`Error running ${lib.name}:`, error) 581 - results.push({ 582 - library: lib.name, 583 - testName: testCase.name, 584 - error: error.message, 585 - }) 586 - } 587 - 588 - // Small delay between libraries 589 - await new Promise((resolve) => setTimeout(resolve, 50)) 590 - } 591 - 592 - return results 593 - } 594 - } 595 - 596 - // Test cases 597 215 const testCases = [ 598 216 { 599 - name: "Simple Text Update", 600 - description: "Morphing a single text node change", 601 - setup: () => { 602 - const from = document.createElement("div") 603 - from.innerHTML = "<p>Hello World</p>" 604 - const to = document.createElement("div") 605 - to.innerHTML = "<p>Hello Morphlex</p>" 606 - return { from: from.firstElementChild, to: to.firstElementChild } 607 - }, 217 + name: "Simple Text Change", 218 + from: "<div>Hello</div>", 219 + to: "<div>World</div>", 608 220 }, 609 221 { 610 - name: "Attribute Changes", 611 - description: "Updating multiple attributes on elements", 612 - setup: () => { 613 - const from = document.createElement("div") 614 - from.innerHTML = ` 615 - <div class="old-class" data-value="1"> 616 - <span id="test" title="old">Content</span> 617 - </div> 618 - ` 619 - const to = document.createElement("div") 620 - to.innerHTML = ` 621 - <div class="new-class" data-value="2" data-new="true"> 622 - <span id="test" title="new" aria-label="label">Content</span> 623 - </div> 624 - ` 625 - return { from: from.firstElementChild, to: to.firstElementChild } 626 - }, 222 + name: "Attribute Update", 223 + from: '<div class="foo" id="test">Content</div>', 224 + to: '<div class="bar" id="test">Content</div>', 627 225 }, 628 226 { 629 - name: "List Reordering", 630 - description: "Reordering items in a list", 631 - setup: () => { 632 - const from = document.createElement("ul") 633 - from.innerHTML = ` 634 - <li>First</li> 635 - <li>Second</li> 636 - <li>Third</li> 637 - <li>Fourth</li> 638 - <li>Fifth</li> 639 - ` 640 - const to = document.createElement("ul") 641 - to.innerHTML = ` 642 - <li>Third</li> 643 - <li>First</li> 644 - <li>Fifth</li> 645 - <li>Second</li> 646 - <li>Fourth</li> 647 - ` 648 - return { from, to } 649 - }, 227 + name: "Add Children", 228 + from: "<ul><li>One</li></ul>", 229 + to: "<ul><li>One</li><li>Two</li><li>Three</li></ul>", 650 230 }, 651 231 { 652 - name: "Large List", 653 - description: "Morphing a list with 50 items", 654 - setup: () => { 655 - const from = document.createElement("ul") 656 - for (let i = 1; i <= 50; i++) { 657 - const li = document.createElement("li") 658 - li.textContent = `Item ${i}` 659 - from.appendChild(li) 660 - } 661 - const to = document.createElement("ul") 662 - const indices = Array.from({ length: 50 }, (_, i) => i + 1) 663 - // Simple shuffle 664 - for (let i = indices.length - 1; i > 0; i--) { 665 - const j = Math.floor(Math.random() * (i + 1)) 666 - ;[indices[i], indices[j]] = [indices[j], indices[i]] 667 - } 668 - for (const i of indices.slice(0, 45)) { 669 - const li = document.createElement("li") 670 - li.textContent = i % 3 === 0 ? `Modified Item ${i}` : `Item ${i}` 671 - to.appendChild(li) 672 - } 673 - return { from, to } 674 - }, 232 + name: "Remove Children", 233 + from: "<ul><li>One</li><li>Two</li><li>Three</li></ul>", 234 + to: "<ul><li>One</li></ul>", 675 235 }, 676 236 { 677 - name: "Large List - Add One Item", 678 - description: "Adding a single item to a list of 100 items", 679 - setup: () => { 680 - const from = document.createElement("ul") 681 - for (let i = 1; i <= 100; i++) { 682 - const li = document.createElement("li") 683 - li.textContent = `Item ${i}` 684 - from.appendChild(li) 685 - } 686 - const to = document.createElement("ul") 687 - for (let i = 1; i <= 100; i++) { 688 - const li = document.createElement("li") 689 - li.textContent = `Item ${i}` 690 - to.appendChild(li) 691 - } 692 - // Add new item at position 50 693 - const newLi = document.createElement("li") 694 - newLi.textContent = "New Item" 695 - to.insertBefore(newLi, to.children[50]) 696 - return { from, to } 697 - }, 698 - }, 699 - { 700 - name: "Large List - Remove One Item", 701 - description: "Removing a single item from a list of 100 items", 702 - setup: () => { 703 - const from = document.createElement("ul") 704 - for (let i = 1; i <= 100; i++) { 705 - const li = document.createElement("li") 706 - li.textContent = `Item ${i}` 707 - from.appendChild(li) 708 - } 709 - const to = document.createElement("ul") 710 - for (let i = 1; i <= 100; i++) { 711 - if (i === 50) continue // Skip item 50 712 - const li = document.createElement("li") 713 - li.textContent = `Item ${i}` 714 - to.appendChild(li) 715 - } 716 - return { from, to } 717 - }, 237 + name: "Reorder Children", 238 + from: '<ul><li id="a">A</li><li id="b">B</li><li id="c">C</li></ul>', 239 + to: '<ul><li id="c">C</li><li id="a">A</li><li id="b">B</li></ul>', 718 240 }, 719 241 { 720 - name: "Large List - Resort All Items", 721 - description: "Resorting all items in a list of 100 items", 722 - setup: () => { 723 - const from = document.createElement("ul") 724 - for (let i = 1; i <= 100; i++) { 725 - const li = document.createElement("li") 726 - li.textContent = `Item ${i}` 727 - from.appendChild(li) 728 - } 729 - const to = document.createElement("ul") 730 - // Reverse the order 731 - for (let i = 100; i >= 1; i--) { 732 - const li = document.createElement("li") 733 - li.textContent = `Item ${i}` 734 - to.appendChild(li) 735 - } 736 - return { from, to } 737 - }, 242 + name: "Deep Nested Update", 243 + from: "<div><div><div><span>Deep</span></div></div></div>", 244 + to: "<div><div><div><span>Nested</span></div></div></div>", 738 245 }, 739 246 { 740 - name: "Large List - Partial Reorder", 741 - description: "Reordering some items in a list of 100 items while keeping many in place", 742 - setup: () => { 743 - const from = document.createElement("ul") 744 - for (let i = 1; i <= 100; i++) { 745 - const li = document.createElement("li") 746 - li.id = `item-${i}` 747 - li.textContent = `Item ${i}` 748 - from.appendChild(li) 749 - } 750 - const to = document.createElement("ul") 751 - // Partial reorder: move every 5th item to a different position 752 - // [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,...] 753 - // This keeps most items in order (good for LIS) while shuffling some 754 - const items = [] 755 - for (let i = 1; i <= 100; i++) { 756 - items.push(i) 757 - } 758 - const reordered = [] 759 - for (let i = 0; i < items.length; i += 5) { 760 - if (i + 4 < items.length) { 761 - // Move 5th item to front of group 762 - reordered.push(items[i + 4]) 763 - reordered.push(items[i]) 764 - reordered.push(items[i + 1]) 765 - reordered.push(items[i + 2]) 766 - reordered.push(items[i + 3]) 767 - } else { 768 - // Handle remaining items 769 - for (let j = i; j < items.length; j++) { 770 - reordered.push(items[j]) 771 - } 772 - } 773 - } 774 - for (const num of reordered) { 775 - const li = document.createElement("li") 776 - li.id = `item-${num}` 777 - li.textContent = `Item ${num}` 778 - to.appendChild(li) 779 - } 780 - return { from, to } 781 - }, 247 + name: "Large List (100 items)", 248 + from: "<ul>" + Array.from({ length: 100 }, (_, i) => `<li id="item-${i}">Item ${i}</li>`).join("") + "</ul>", 249 + to: "<ul>" + Array.from({ length: 100 }, (_, i) => `<li id="item-${i}">Item ${i * 2}</li>`).join("") + "</ul>", 782 250 }, 783 251 { 784 - name: "Deep Nesting", 785 - description: "Morphing deeply nested structures", 786 - setup: () => { 787 - const from = document.createElement("div") 788 - from.innerHTML = ` 789 - <div> 790 - <section> 791 - <article> 792 - <header> 793 - <h1>Title</h1> 794 - <p>Subtitle</p> 795 - </header> 796 - <div> 797 - <p>Paragraph 1</p> 798 - <p>Paragraph 2</p> 799 - </div> 800 - </article> 801 - </section> 802 - </div> 803 - ` 804 - const to = document.createElement("div") 805 - to.innerHTML = ` 806 - <div> 807 - <section> 808 - <article> 809 - <header> 810 - <h1>New Title</h1> 811 - <p>New Subtitle</p> 812 - </header> 813 - <div> 814 - <p>Modified Paragraph 1</p> 815 - <p>Paragraph 2</p> 816 - <p>Paragraph 3</p> 817 - </div> 818 - </article> 819 - </section> 820 - </div> 821 - ` 822 - return { from: from.firstElementChild, to: to.firstElementChild } 823 - }, 252 + name: "Mixed Operations", 253 + from: '<div class="old"><p>Text</p><span id="keep">Keep</span></div>', 254 + to: '<div class="new"><span id="keep">Keep</span><p>New Text</p><a href="#">Link</a></div>', 824 255 }, 825 256 ] 826 257 827 - // UI Functions 828 - function updateProgress(current, total) { 829 - const percent = (current / total) * 100 830 - document.getElementById("progress").style.width = `${percent}%` 831 - } 832 - 833 - function displayResults(allResults) { 834 - const resultsEl = document.getElementById("results") 835 - resultsEl.innerHTML = "" 836 - 837 - // Group results by test case 838 - const testGroups = {} 839 - allResults.forEach((result) => { 840 - if (!testGroups[result.testName]) { 841 - testGroups[result.testName] = [] 842 - } 843 - testGroups[result.testName].push(result) 844 - }) 258 + const sandbox = document.getElementById("sandbox") 259 + const runBtn = document.getElementById("runBtn") 260 + const benchmarkSelect = document.getElementById("benchmarkSelect") 261 + const iterationsInput = document.getElementById("iterations") 262 + const progressDiv = document.getElementById("progress") 263 + const resultsDiv = document.getElementById("results") 264 + const resultsContainer = document.getElementById("resultsContainer") 845 265 846 - // Calculate overall stats first for summary 847 - const libraryStats = {} 848 - allResults.forEach((result) => { 849 - if (!result.error) { 850 - if (!libraryStats[result.library]) { 851 - libraryStats[result.library] = { 852 - totalTime: 0, 853 - count: 0, 854 - wins: 0, 855 - } 856 - } 857 - libraryStats[result.library].totalTime += result.averageTime 858 - libraryStats[result.library].count++ 859 - } 860 - }) 266 + // Populate benchmark select 267 + testCases.forEach((testCase, index) => { 268 + const option = document.createElement("option") 269 + option.value = index 270 + option.textContent = testCase.name 271 + benchmarkSelect.appendChild(option) 272 + }) 861 273 862 - // Count wins 863 - Object.keys(testGroups).forEach((testName) => { 864 - const testResults = allResults.filter((r) => r.testName === testName && !r.error) 865 - if (testResults.length > 0) { 866 - const winner = testResults.reduce((min, r) => (r.averageTime < min.averageTime ? r : min)) 867 - if (libraryStats[winner.library]) { 868 - libraryStats[winner.library].wins++ 869 - } 870 - } 871 - }) 274 + function createElements(html) { 275 + const temp = document.createElement("div") 276 + temp.innerHTML = html 277 + return temp.firstChild 278 + } 872 279 873 - const summaryData = Object.entries(libraryStats) 874 - .map(([library, stats]) => ({ 875 - library, 876 - avgTime: stats.totalTime / stats.count, 877 - wins: stats.wins, 878 - totalTests: stats.count, 879 - })) 880 - .sort((a, b) => a.avgTime - b.avgTime) 280 + async function runBenchmark(testCase, iterations) { 281 + const times = [] 881 282 882 - // Create summary section 883 - const summary = document.createElement("div") 884 - summary.className = "summary" 885 - summary.innerHTML = "<h2>🏆 Overall Results</h2>" 283 + for (let i = 0; i < iterations; i++) { 284 + const from = createElements(testCase.from) 285 + const to = createElements(testCase.to) 286 + sandbox.appendChild(from) 886 287 887 - const summaryGrid = document.createElement("div") 888 - summaryGrid.className = "summary-grid" 889 - 890 - summaryData.forEach((data, index) => { 891 - const card = document.createElement("div") 892 - card.className = "summary-card" 893 - const medal = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `#${index + 1}` 894 - const avgTime = data.avgTime < 0.001 ? data.avgTime.toExponential(2) : data.avgTime.toFixed(4) 288 + const start = performance.now() 289 + morph(from, to) 290 + const end = performance.now() 895 291 896 - card.innerHTML = ` 897 - <div style="font-size: 2rem; margin-bottom: 0.5rem">${medal}</div> 898 - <div class="summary-library">${data.library}</div> 899 - <div class="summary-score">${avgTime} ms</div> 900 - <div style="margin-top: 0.5rem; font-size: 0.9rem"> 901 - ${data.wins} wins / ${data.totalTests} tests 902 - </div> 903 - ` 904 - summaryGrid.appendChild(card) 905 - }) 292 + times.push(end - start) 293 + sandbox.innerHTML = "" 906 294 907 - summary.appendChild(summaryGrid) 908 - resultsEl.appendChild(summary) 295 + // Yield to browser occasionally 296 + if (i % 100 === 0) { 297 + await new Promise((resolve) => setTimeout(resolve, 0)) 298 + } 299 + } 909 300 910 - // Display each test case 911 - Object.entries(testGroups).forEach(([testName, results]) => { 912 - const testCase = document.createElement("div") 913 - testCase.className = "test-case" 914 - 915 - const testDesc = testCases.find((t) => t.name === testName) 916 - testCase.innerHTML = ` 917 - <h3>${testName}</h3> 918 - <p class="test-description">${testDesc ? testDesc.description : ""}</p> 919 - ` 920 - 921 - // Sort by performance 922 - const sorted = [...results].sort((a, b) => { 923 - if (a.error) return 1 924 - if (b.error) return -1 925 - return a.averageTime - b.averageTime 926 - }) 927 - 928 - // Create visual results for each library 929 - sorted.forEach((result, index) => { 930 - const libResult = document.createElement("div") 931 - libResult.className = "library-result" 932 - 933 - if (result.error) { 934 - libResult.innerHTML = ` 935 - <div class="library-name">${result.library}</div> 936 - <div class="error">ERROR: ${result.error}</div> 937 - ` 938 - } else { 939 - const msOp = result.averageTime < 0.001 ? result.averageTime.toExponential(2) : result.averageTime.toFixed(4) 940 - const opsPerSec = 941 - result.opsPerSecond > 1000000 942 - ? result.opsPerSecond.toExponential(2) 943 - : Math.round(result.opsPerSecond).toLocaleString() 944 - 945 - const dom = result.domOperations 946 - const rankBadge = index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : "" 301 + times.sort((a, b) => a - b) 302 + const total = times.reduce((sum, t) => sum + t, 0) 303 + const avg = total / times.length 304 + const median = times[Math.floor(times.length / 2)] 305 + const min = times[0] 306 + const max = times[times.length - 1] 307 + const p95 = times[Math.floor(times.length * 0.95)] 947 308 948 - libResult.innerHTML = ` 949 - <div class="library-name">${result.library}</div> 950 - <div class="metrics"> 951 - <div class="metric"> 952 - <div class="metric-value">${msOp}</div> 953 - <div class="metric-label">ms/op</div> 954 - </div> 955 - <div class="metric"> 956 - <div class="metric-value">${opsPerSec}</div> 957 - <div class="metric-label">ops/sec</div> 958 - </div> 959 - <div class="metric"> 960 - <div class="metric-value">${dom ? dom.total : "N/A"}</div> 961 - <div class="metric-label">DOM ops</div> 962 - </div> 963 - <div class="metric"> 964 - <div class="metric-value">${dom ? dom.addedNodes + dom.removedNodes : "N/A"}</div> 965 - <div class="metric-label">nodes +/-</div> 966 - </div> 967 - <div class="metric"> 968 - <div class="metric-value">${dom ? dom.attributes : "N/A"}</div> 969 - <div class="metric-label">attrs</div> 970 - </div> 971 - </div> 972 - <div class="rank rank-${index + 1}">${rankBadge}</div> 973 - ` 974 - } 309 + return { total, avg, median, min, max, p95, times: times.length } 310 + } 975 311 976 - testCase.appendChild(libResult) 977 - }) 312 + function displayResults(results) { 313 + resultsContainer.innerHTML = results 314 + .map( 315 + (result) => ` 316 + <div class="result-item"> 317 + <div class="result-header"> 318 + <span class="result-name">${result.name}</span> 319 + <span class="result-time">${result.avg.toFixed(3)}ms</span> 320 + </div> 321 + <div class="result-details"> 322 + <div class="detail"> 323 + <span class="detail-label">Median</span> 324 + <span class="detail-value">${result.median.toFixed(3)}ms</span> 325 + </div> 326 + <div class="detail"> 327 + <span class="detail-label">Min</span> 328 + <span class="detail-value">${result.min.toFixed(3)}ms</span> 329 + </div> 330 + <div class="detail"> 331 + <span class="detail-label">Max</span> 332 + <span class="detail-value">${result.max.toFixed(3)}ms</span> 333 + </div> 334 + <div class="detail"> 335 + <span class="detail-label">P95</span> 336 + <span class="detail-value">${result.p95.toFixed(3)}ms</span> 337 + </div> 338 + <div class="detail"> 339 + <span class="detail-label">Total</span> 340 + <span class="detail-value">${result.total.toFixed(1)}ms</span> 341 + </div> 342 + <div class="detail"> 343 + <span class="detail-label">Iterations</span> 344 + <span class="detail-value">${result.times}</span> 345 + </div> 346 + </div> 347 + </div> 348 + `, 349 + ) 350 + .join("") 978 351 979 - resultsEl.appendChild(testCase) 980 - }) 352 + resultsDiv.classList.add("visible") 981 353 } 982 354 983 - // Event handlers 984 - document.getElementById("runBtn").addEventListener("click", async () => { 985 - if (isRunning) return 355 + runBtn.addEventListener("click", async () => { 356 + const iterations = parseInt(iterationsInput.value) 357 + if (iterations < 1) return 986 358 987 - isRunning = true 988 - stopRequested = false 989 - const runBtn = document.getElementById("runBtn") 990 - const stopBtn = document.getElementById("stopBtn") 991 - const statusEl = document.getElementById("status") 992 - const resultsEl = document.getElementById("results") 359 + const selectedValue = benchmarkSelect.value 360 + const selectedTests = selectedValue === "all" ? testCases : [testCases[parseInt(selectedValue)]] 993 361 994 - runBtn.style.display = "none" 995 - stopBtn.style.display = "inline-block" 996 - statusEl.textContent = "Running..." 997 - statusEl.className = "status running" 998 - resultsEl.innerHTML = "" 362 + runBtn.disabled = true 363 + resultsDiv.classList.remove("visible") 364 + progressDiv.textContent = "Running benchmarks..." 999 365 1000 - const iterations = parseInt(document.getElementById("iterations").value) 1001 - const warmup = parseInt(document.getElementById("warmup").value) 366 + const results = [] 1002 367 1003 - const benchmark = new BrowserBenchmark(iterations, warmup) 1004 - const totalTests = testCases.length * 4 // 4 libraries per test 1005 - let completedTests = 0 368 + for (let i = 0; i < selectedTests.length; i++) { 369 + const testCase = selectedTests[i] 370 + progressDiv.textContent = `Running ${testCase.name} (${i + 1}/${selectedTests.length})...` 1006 371 1007 - for (const testCase of testCases) { 1008 - if (stopRequested) break 372 + const result = await runBenchmark(testCase, iterations) 373 + results.push({ name: testCase.name, ...result }) 1009 374 1010 - await benchmark.runTestCase(testCase, (result) => { 1011 - completedTests++ 1012 - updateProgress(completedTests, totalTests) 1013 - }) 375 + await new Promise((resolve) => setTimeout(resolve, 100)) 1014 376 } 1015 377 1016 - if (!stopRequested && benchmark.results.length > 0) { 1017 - displayResults(benchmark.results) 1018 - } 1019 - 1020 - isRunning = false 1021 - runBtn.style.display = "inline-block" 1022 - stopBtn.style.display = "none" 1023 - statusEl.textContent = stopRequested ? "Stopped" : "Complete" 1024 - statusEl.className = stopRequested ? "status" : "status complete" 1025 - updateProgress(100, 100) 1026 - }) 1027 - 1028 - document.getElementById("stopBtn").addEventListener("click", () => { 1029 - stopRequested = true 378 + progressDiv.textContent = "" 379 + displayResults(results) 380 + runBtn.disabled = false 1030 381 }) 1031 382 </script> 1032 383 </body>
-384
benchmark/simple.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>Morphlex Benchmark</title> 7 - <style> 8 - * { 9 - margin: 0; 10 - padding: 0; 11 - box-sizing: border-box; 12 - } 13 - 14 - body { 15 - font-family: 16 - system-ui, 17 - -apple-system, 18 - sans-serif; 19 - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 20 - min-height: 100vh; 21 - padding: 2rem; 22 - color: #1f2937; 23 - } 24 - 25 - .container { 26 - max-width: 900px; 27 - margin: 0 auto; 28 - } 29 - 30 - header { 31 - text-align: center; 32 - color: white; 33 - margin-bottom: 2rem; 34 - } 35 - 36 - h1 { 37 - font-size: 2.5rem; 38 - margin-bottom: 0.5rem; 39 - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); 40 - } 41 - 42 - .subtitle { 43 - font-size: 1.1rem; 44 - opacity: 0.9; 45 - } 46 - 47 - .card { 48 - background: white; 49 - border-radius: 12px; 50 - padding: 2rem; 51 - margin-bottom: 1.5rem; 52 - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); 53 - } 54 - 55 - .controls { 56 - display: flex; 57 - gap: 1rem; 58 - flex-wrap: wrap; 59 - align-items: center; 60 - margin-bottom: 1.5rem; 61 - } 62 - 63 - label { 64 - font-weight: 600; 65 - display: flex; 66 - align-items: center; 67 - gap: 0.5rem; 68 - } 69 - 70 - input[type="number"], 71 - select { 72 - padding: 0.5rem; 73 - border: 2px solid #e5e7eb; 74 - border-radius: 6px; 75 - font-size: 1rem; 76 - } 77 - 78 - input[type="number"] { 79 - width: 100px; 80 - } 81 - 82 - select { 83 - min-width: 200px; 84 - } 85 - 86 - button { 87 - padding: 0.75rem 2rem; 88 - background: #6366f1; 89 - color: white; 90 - border: none; 91 - border-radius: 6px; 92 - font-size: 1rem; 93 - font-weight: 600; 94 - cursor: pointer; 95 - transition: all 0.2s; 96 - } 97 - 98 - button:hover { 99 - background: #4f46e5; 100 - transform: translateY(-1px); 101 - } 102 - 103 - button:disabled { 104 - background: #9ca3af; 105 - cursor: not-allowed; 106 - transform: none; 107 - } 108 - 109 - .results { 110 - display: none; 111 - } 112 - 113 - .results.visible { 114 - display: block; 115 - } 116 - 117 - .result-item { 118 - padding: 1rem; 119 - border-bottom: 1px solid #e5e7eb; 120 - } 121 - 122 - .result-item:last-child { 123 - border-bottom: none; 124 - } 125 - 126 - .result-header { 127 - display: flex; 128 - justify-content: space-between; 129 - align-items: center; 130 - margin-bottom: 0.5rem; 131 - } 132 - 133 - .result-name { 134 - font-weight: 600; 135 - font-size: 1.1rem; 136 - } 137 - 138 - .result-time { 139 - font-size: 1.5rem; 140 - font-weight: 700; 141 - color: #6366f1; 142 - } 143 - 144 - .result-details { 145 - display: grid; 146 - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 147 - gap: 1rem; 148 - margin-top: 0.75rem; 149 - } 150 - 151 - .detail { 152 - display: flex; 153 - flex-direction: column; 154 - } 155 - 156 - .detail-label { 157 - font-size: 0.75rem; 158 - color: #6b7280; 159 - text-transform: uppercase; 160 - letter-spacing: 0.5px; 161 - } 162 - 163 - .detail-value { 164 - font-weight: 600; 165 - font-size: 1rem; 166 - } 167 - 168 - .progress { 169 - margin-top: 0.75rem; 170 - text-align: center; 171 - color: #6b7280; 172 - font-style: italic; 173 - } 174 - 175 - #sandbox { 176 - display: none; 177 - } 178 - </style> 179 - </head> 180 - <body> 181 - <div class="container"> 182 - <header> 183 - <h1>Morphlex Benchmark</h1> 184 - <p class="subtitle">Performance testing for DOM morphing</p> 185 - </header> 186 - 187 - <div class="card"> 188 - <div class="controls"> 189 - <label> 190 - Benchmark: 191 - <select id="benchmarkSelect"> 192 - <option value="all">All Benchmarks</option> 193 - </select> 194 - </label> 195 - <label> 196 - Iterations: 197 - <input type="number" id="iterations" value="1000" min="100" step="100" /> 198 - </label> 199 - <button id="runBtn">Run Benchmark</button> 200 - </div> 201 - <div class="progress" id="progress"></div> 202 - </div> 203 - 204 - <div class="card results" id="results"> 205 - <h2 style="margin-bottom: 1.5rem">Results</h2> 206 - <div id="resultsContainer"></div> 207 - </div> 208 - </div> 209 - 210 - <div id="sandbox"></div> 211 - 212 - <script type="module"> 213 - import { morph } from "../dist/morphlex.js" 214 - 215 - const testCases = [ 216 - { 217 - name: "Simple Text Change", 218 - from: "<div>Hello</div>", 219 - to: "<div>World</div>", 220 - }, 221 - { 222 - name: "Attribute Update", 223 - from: '<div class="foo" id="test">Content</div>', 224 - to: '<div class="bar" id="test">Content</div>', 225 - }, 226 - { 227 - name: "Add Children", 228 - from: "<ul><li>One</li></ul>", 229 - to: "<ul><li>One</li><li>Two</li><li>Three</li></ul>", 230 - }, 231 - { 232 - name: "Remove Children", 233 - from: "<ul><li>One</li><li>Two</li><li>Three</li></ul>", 234 - to: "<ul><li>One</li></ul>", 235 - }, 236 - { 237 - name: "Reorder Children", 238 - from: '<ul><li id="a">A</li><li id="b">B</li><li id="c">C</li></ul>', 239 - to: '<ul><li id="c">C</li><li id="a">A</li><li id="b">B</li></ul>', 240 - }, 241 - { 242 - name: "Deep Nested Update", 243 - from: "<div><div><div><span>Deep</span></div></div></div>", 244 - to: "<div><div><div><span>Nested</span></div></div></div>", 245 - }, 246 - { 247 - name: "Large List (100 items)", 248 - from: "<ul>" + Array.from({ length: 100 }, (_, i) => `<li id="item-${i}">Item ${i}</li>`).join("") + "</ul>", 249 - to: "<ul>" + Array.from({ length: 100 }, (_, i) => `<li id="item-${i}">Item ${i * 2}</li>`).join("") + "</ul>", 250 - }, 251 - { 252 - name: "Mixed Operations", 253 - from: '<div class="old"><p>Text</p><span id="keep">Keep</span></div>', 254 - to: '<div class="new"><span id="keep">Keep</span><p>New Text</p><a href="#">Link</a></div>', 255 - }, 256 - ] 257 - 258 - const sandbox = document.getElementById("sandbox") 259 - const runBtn = document.getElementById("runBtn") 260 - const benchmarkSelect = document.getElementById("benchmarkSelect") 261 - const iterationsInput = document.getElementById("iterations") 262 - const progressDiv = document.getElementById("progress") 263 - const resultsDiv = document.getElementById("results") 264 - const resultsContainer = document.getElementById("resultsContainer") 265 - 266 - // Populate benchmark select 267 - testCases.forEach((testCase, index) => { 268 - const option = document.createElement("option") 269 - option.value = index 270 - option.textContent = testCase.name 271 - benchmarkSelect.appendChild(option) 272 - }) 273 - 274 - function createElements(html) { 275 - const temp = document.createElement("div") 276 - temp.innerHTML = html 277 - return temp.firstChild 278 - } 279 - 280 - async function runBenchmark(testCase, iterations) { 281 - const times = [] 282 - 283 - for (let i = 0; i < iterations; i++) { 284 - const from = createElements(testCase.from) 285 - const to = createElements(testCase.to) 286 - sandbox.appendChild(from) 287 - 288 - const start = performance.now() 289 - morph(from, to) 290 - const end = performance.now() 291 - 292 - times.push(end - start) 293 - sandbox.innerHTML = "" 294 - 295 - // Yield to browser occasionally 296 - if (i % 100 === 0) { 297 - await new Promise((resolve) => setTimeout(resolve, 0)) 298 - } 299 - } 300 - 301 - times.sort((a, b) => a - b) 302 - const total = times.reduce((sum, t) => sum + t, 0) 303 - const avg = total / times.length 304 - const median = times[Math.floor(times.length / 2)] 305 - const min = times[0] 306 - const max = times[times.length - 1] 307 - const p95 = times[Math.floor(times.length * 0.95)] 308 - 309 - return { total, avg, median, min, max, p95, times: times.length } 310 - } 311 - 312 - function displayResults(results) { 313 - resultsContainer.innerHTML = results 314 - .map( 315 - (result) => ` 316 - <div class="result-item"> 317 - <div class="result-header"> 318 - <span class="result-name">${result.name}</span> 319 - <span class="result-time">${result.avg.toFixed(3)}ms</span> 320 - </div> 321 - <div class="result-details"> 322 - <div class="detail"> 323 - <span class="detail-label">Median</span> 324 - <span class="detail-value">${result.median.toFixed(3)}ms</span> 325 - </div> 326 - <div class="detail"> 327 - <span class="detail-label">Min</span> 328 - <span class="detail-value">${result.min.toFixed(3)}ms</span> 329 - </div> 330 - <div class="detail"> 331 - <span class="detail-label">Max</span> 332 - <span class="detail-value">${result.max.toFixed(3)}ms</span> 333 - </div> 334 - <div class="detail"> 335 - <span class="detail-label">P95</span> 336 - <span class="detail-value">${result.p95.toFixed(3)}ms</span> 337 - </div> 338 - <div class="detail"> 339 - <span class="detail-label">Total</span> 340 - <span class="detail-value">${result.total.toFixed(1)}ms</span> 341 - </div> 342 - <div class="detail"> 343 - <span class="detail-label">Iterations</span> 344 - <span class="detail-value">${result.times}</span> 345 - </div> 346 - </div> 347 - </div> 348 - `, 349 - ) 350 - .join("") 351 - 352 - resultsDiv.classList.add("visible") 353 - } 354 - 355 - runBtn.addEventListener("click", async () => { 356 - const iterations = parseInt(iterationsInput.value) 357 - if (iterations < 1) return 358 - 359 - const selectedValue = benchmarkSelect.value 360 - const selectedTests = selectedValue === "all" ? testCases : [testCases[parseInt(selectedValue)]] 361 - 362 - runBtn.disabled = true 363 - resultsDiv.classList.remove("visible") 364 - progressDiv.textContent = "Running benchmarks..." 365 - 366 - const results = [] 367 - 368 - for (let i = 0; i < selectedTests.length; i++) { 369 - const testCase = selectedTests[i] 370 - progressDiv.textContent = `Running ${testCase.name} (${i + 1}/${selectedTests.length})...` 371 - 372 - const result = await runBenchmark(testCase, iterations) 373 - results.push({ name: testCase.name, ...result }) 374 - 375 - await new Promise((resolve) => setTimeout(resolve, 100)) 376 - } 377 - 378 - progressDiv.textContent = "" 379 - displayResults(results) 380 - runBtn.disabled = false 381 - }) 382 - </script> 383 - </body> 384 - </html>
-367
src/data-morph.ts
··· 1 - type HTMLOrSVG = HTMLElement | SVGElement | MathMLElement 2 - 3 - export const isHTMLOrSVG = (el: Node): el is HTMLOrSVG => 4 - el instanceof HTMLElement || el instanceof SVGElement || el instanceof MathMLElement 5 - 6 - const ctxIdMap = new Map<Node, Set<string>>() 7 - const ctxPersistentIds = new Set<string>() 8 - const oldIdTagNameMap = new Map<string, string>() 9 - const duplicateIds = new Set<string>() 10 - const ctxPantry = document.createElement("div") 11 - ctxPantry.hidden = true 12 - 13 - const aliasedIgnoreMorph = "ignore-morph" 14 - const aliasedIgnoreMorphAttr = `[${aliasedIgnoreMorph}]` 15 - export const morph = ( 16 - oldElt: Element | ShadowRoot, 17 - newContent: DocumentFragment | Element, 18 - mode: "outer" | "inner" = "outer", 19 - ): void => { 20 - if ( 21 - (isHTMLOrSVG(oldElt) && 22 - isHTMLOrSVG(newContent) && 23 - oldElt.hasAttribute(aliasedIgnoreMorph) && 24 - newContent.hasAttribute(aliasedIgnoreMorph)) || 25 - oldElt.parentElement?.closest(aliasedIgnoreMorphAttr) 26 - ) { 27 - return 28 - } 29 - 30 - const normalizedElt = document.createElement("div") 31 - normalizedElt.append(newContent) 32 - document.body.insertAdjacentElement("afterend", ctxPantry) 33 - 34 - // Computes the set of IDs that persist between the two contents excluding duplicates 35 - const oldIdElements = oldElt.querySelectorAll("[id]") 36 - for (const { id, tagName } of oldIdElements) { 37 - if (oldIdTagNameMap.has(id)) { 38 - duplicateIds.add(id) 39 - } else { 40 - oldIdTagNameMap.set(id, tagName) 41 - } 42 - } 43 - if (oldElt instanceof Element && oldElt.id) { 44 - if (oldIdTagNameMap.has(oldElt.id)) { 45 - duplicateIds.add(oldElt.id) 46 - } else { 47 - oldIdTagNameMap.set(oldElt.id, oldElt.tagName) 48 - } 49 - } 50 - 51 - ctxPersistentIds.clear() 52 - const newIdElements = normalizedElt.querySelectorAll("[id]") 53 - for (const { id, tagName } of newIdElements) { 54 - if (ctxPersistentIds.has(id)) { 55 - duplicateIds.add(id) 56 - } else if (oldIdTagNameMap.get(id) === tagName) { 57 - ctxPersistentIds.add(id) 58 - } 59 - } 60 - 61 - for (const id of duplicateIds) { 62 - ctxPersistentIds.delete(id) 63 - } 64 - 65 - oldIdTagNameMap.clear() 66 - duplicateIds.clear() 67 - ctxIdMap.clear() 68 - 69 - const parent = mode === "outer" ? oldElt.parentElement! : oldElt 70 - populateIdMapWithTree(parent, oldIdElements) 71 - populateIdMapWithTree(normalizedElt, newIdElements) 72 - 73 - morphChildren(parent, normalizedElt, mode === "outer" ? oldElt : null, oldElt.nextSibling) 74 - 75 - ctxPantry.remove() 76 - } 77 - 78 - // This is the core algorithm for matching up children. 79 - // The idea is to use ID sets to try to match up nodes as faithfully as possible. 80 - // We greedily match, which allows us to keep the algorithm fast, 81 - // but by using ID sets, we are able to better match up with content deeper in the DOM. 82 - const morphChildren = ( 83 - oldParent: Element | ShadowRoot, // the old content that we are merging the new content into 84 - newParent: Element, // the parent element of the new content 85 - insertionPoint: Node | null = null, // the point in the DOM we start morphing at (defaults to first child) 86 - endPoint: Node | null = null, // the point in the DOM we stop morphing at (defaults to after last child) 87 - ): void => { 88 - // normalize 89 - if (oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement) { 90 - // we can pretend the DocumentElement is an Element 91 - oldParent = oldParent.content as unknown as Element 92 - newParent = newParent.content as unknown as Element 93 - } 94 - insertionPoint ??= oldParent.firstChild 95 - 96 - // run through all the new content 97 - for (const newChild of newParent.childNodes) { 98 - // once we reach the end of the old parent content skip to the end and insert the rest 99 - if (insertionPoint && insertionPoint !== endPoint) { 100 - const bestMatch = findBestMatch(newChild, insertionPoint, endPoint) 101 - if (bestMatch) { 102 - // if the node to morph is not at the insertion point then remove/move up to it 103 - if (bestMatch !== insertionPoint) { 104 - let cursor: Node | null = insertionPoint 105 - // Remove nodes between the start and end nodes 106 - while (cursor && cursor !== bestMatch) { 107 - const tempNode = cursor 108 - cursor = cursor.nextSibling 109 - removeNode(tempNode) 110 - } 111 - } 112 - morphNode(bestMatch, newChild) 113 - insertionPoint = bestMatch.nextSibling 114 - continue 115 - } 116 - } 117 - 118 - // if the matching node is elsewhere in the original content 119 - if (newChild instanceof Element && ctxPersistentIds.has(newChild.id)) { 120 - // move it and all its children here and morph, will always be found 121 - // Search for an element by ID within the document and pantry, and move it using moveBefore. 122 - const movedChild = document.getElementById(newChild.id) as Element 123 - 124 - // Removes an element from its ancestors' ID maps. 125 - // This is needed when an element is moved from the "future" via `moveBeforeId`. 126 - // Otherwise, its erstwhile ancestors could be mistakenly moved to the pantry rather than being deleted, 127 - // preventing their removal hooks from being called. 128 - let current = movedChild 129 - while ((current = current.parentNode as Element)) { 130 - const idSet = ctxIdMap.get(current) 131 - if (idSet) { 132 - idSet.delete(newChild.id) 133 - if (!idSet.size) { 134 - ctxIdMap.delete(current) 135 - } 136 - } 137 - } 138 - 139 - moveBefore(oldParent, movedChild, insertionPoint) 140 - morphNode(movedChild, newChild) 141 - insertionPoint = movedChild.nextSibling 142 - continue 143 - } 144 - 145 - // This performs the action of inserting a new node while handling situations where the node contains 146 - // elements with persistent IDs and possible state info we can still preserve by moving in and then morphing 147 - if (ctxIdMap.has(newChild)) { 148 - // node has children with IDs with possible state so create a dummy elt of same type and apply full morph algorithm 149 - const newEmptyChild = document.createElement((newChild as Element).tagName) 150 - oldParent.insertBefore(newEmptyChild, insertionPoint) 151 - morphNode(newEmptyChild, newChild) 152 - insertionPoint = newEmptyChild.nextSibling 153 - } else { 154 - // optimization: no id state to preserve so we can just insert a clone of the newChild and its descendants 155 - const newClonedChild = document.importNode(newChild, true) // importNode to not mutate newParent 156 - oldParent.insertBefore(newClonedChild, insertionPoint) 157 - insertionPoint = newClonedChild.nextSibling 158 - } 159 - } 160 - 161 - // remove any remaining old nodes that didn't match up with new content 162 - while (insertionPoint && insertionPoint !== endPoint) { 163 - const tempNode = insertionPoint 164 - insertionPoint = insertionPoint.nextSibling 165 - removeNode(tempNode) 166 - } 167 - } 168 - 169 - // Scans forward from the startPoint to the endPoint looking for a match for the node. 170 - // It looks for an id set match first, then a soft match. 171 - // We abort soft matching if we find two future soft matches, to reduce churn. 172 - const findBestMatch = (node: Node, startPoint: Node | null, endPoint: Node | null): Node | null => { 173 - let bestMatch: Node | null | undefined = null 174 - let nextSibling = node.nextSibling 175 - let siblingSoftMatchCount = 0 176 - let displaceMatchCount = 0 177 - 178 - // Max ID matches we are willing to displace in our search 179 - const nodeMatchCount = ctxIdMap.get(node)?.size || 0 180 - 181 - let cursor = startPoint 182 - while (cursor && cursor !== endPoint) { 183 - // soft matching is a prerequisite for id set matching 184 - if (isSoftMatch(cursor, node)) { 185 - let isIdSetMatch = false 186 - const oldSet = ctxIdMap.get(cursor) 187 - const newSet = ctxIdMap.get(node) 188 - 189 - if (newSet && oldSet) { 190 - for (const id of oldSet) { 191 - // a potential match is an id in the new and old nodes that 192 - // has not already been merged into the DOM 193 - // But the newNode content we call this on has not been 194 - // merged yet and we don't allow duplicate IDs so it is simple 195 - if (newSet.has(id)) { 196 - isIdSetMatch = true 197 - break 198 - } 199 - } 200 - } 201 - 202 - if (isIdSetMatch) { 203 - return cursor // found an id set match, we're done! 204 - } 205 - 206 - // we haven’t yet saved a soft match fallback 207 - // the current soft match will hard match something else in the future, leave it 208 - if (!bestMatch && !ctxIdMap.has(cursor)) { 209 - // optimization: if node can't id set match, we can just return the soft match immediately 210 - if (!nodeMatchCount) { 211 - return cursor 212 - } 213 - // save this as the fallback if we get through the loop without finding a hard match 214 - bestMatch = cursor 215 - } 216 - } 217 - 218 - // check for IDs we may be displaced when matching 219 - displaceMatchCount += ctxIdMap.get(cursor)?.size || 0 220 - if (displaceMatchCount > nodeMatchCount) { 221 - // if we are going to displace more IDs than the node contains then 222 - // we do not have a good candidate for an ID match, so return 223 - break 224 - } 225 - 226 - if (bestMatch === null && nextSibling && isSoftMatch(cursor, nextSibling)) { 227 - // The next new node has a soft match with this node, so 228 - // increment the count of future soft matches 229 - siblingSoftMatchCount++ 230 - nextSibling = nextSibling.nextSibling 231 - 232 - // If there are two future soft matches, block soft matching for this node to allow 233 - // future siblings to soft match. This is to reduce churn in the DOM when an element 234 - // is prepended. 235 - if (siblingSoftMatchCount >= 2) { 236 - bestMatch = undefined 237 - } 238 - } 239 - 240 - cursor = cursor.nextSibling 241 - } 242 - 243 - return bestMatch || null 244 - } 245 - 246 - // ok to cast: if one is not element, `id` and `tagName` will be null and we'll just compare that. 247 - const isSoftMatch = (oldNode: Node, newNode: Node): boolean => 248 - oldNode.nodeType === newNode.nodeType && 249 - (oldNode as Element).tagName === (newNode as Element).tagName && 250 - // If oldElt has an `id` with possible state and it doesn’t match newElt.id then avoid morphing. 251 - // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, 252 - // its not persistent, and new nodes can't have any hidden state. 253 - (!(oldNode as Element).id || (oldNode as Element).id === (newNode as Element).id) 254 - 255 - // Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: 256 - // - Persistent nodes will be moved to the pantry for later reuse 257 - // - Other nodes will have their hooks called, and then are removed 258 - const removeNode = (node: Node): void => { 259 - // are we going to id set match this later? 260 - ctxIdMap.has(node) 261 - ? // skip callbacks and move to pantry 262 - moveBefore(ctxPantry, node, null) 263 - : // remove for realsies 264 - node.parentNode?.removeChild(node) 265 - } 266 - 267 - // Moves an element before another element within the same parent. 268 - // Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. 269 - // This is essentially a forward-compat wrapper. 270 - const moveBefore: (parentNode: Node, node: Node, after: Node | null) => void = 271 - // @ts-expect-error 272 - removeNode.call.bind(ctxPantry.moveBefore ?? ctxPantry.insertBefore) 273 - 274 - const aliasedPreserveAttr = "preserve-attr" 275 - 276 - // syncs the oldNode to the newNode, copying over all attributes and 277 - // inner element state from the newNode to the oldNode 278 - const morphNode = ( 279 - oldNode: Node, // root node to merge content into 280 - newNode: Node, // new content to merge 281 - ): Node => { 282 - const type = newNode.nodeType 283 - 284 - // if is an element type, sync the attributes from the 285 - // new node into the new node 286 - if (type === 1 /* element type */) { 287 - const oldElt = oldNode as Element 288 - const newElt = newNode as Element 289 - if (oldElt.hasAttribute(aliasedIgnoreMorph) && newElt.hasAttribute(aliasedIgnoreMorph)) { 290 - return oldNode 291 - } 292 - 293 - // many bothans died to bring us this information: 294 - // https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js 295 - // https://github.com/choojs/nanomorph/blob/master/lib/morph.js#L113 296 - if (oldElt instanceof HTMLInputElement && newElt instanceof HTMLInputElement && newElt.type !== "file") { 297 - // https://github.com/bigskysoftware/idiomorph/issues/27 298 - // | old input value | new input value | behaviour | 299 - // | --------------- | ---------------- | -------------------------------------- | 300 - // | `null` | `null` | preserve old input value | 301 - // | some value | the same value | preserve old input value | 302 - // | some value | `null` | set old input value to `""` | 303 - // | `null` | some value | set old input value to new input value | 304 - // | some value | some other value | set old input value to new input value | 305 - if (newElt.getAttribute("value") !== oldElt.getAttribute("value")) { 306 - oldElt.value = newElt.getAttribute("value") ?? "" 307 - } 308 - } else if (oldElt instanceof HTMLTextAreaElement && newElt instanceof HTMLTextAreaElement) { 309 - if (newElt.value !== oldElt.value) { 310 - oldElt.value = newElt.value 311 - } 312 - if (oldElt.firstChild && oldElt.firstChild.nodeValue !== newElt.value) { 313 - oldElt.firstChild.nodeValue = newElt.value 314 - } 315 - } 316 - 317 - const preserveAttrs = ((newNode as HTMLElement).getAttribute(aliasedPreserveAttr) ?? "").split(" ") 318 - 319 - for (const { name, value } of newElt.attributes) { 320 - if (oldElt.getAttribute(name) !== value && !preserveAttrs.includes(name)) { 321 - oldElt.setAttribute(name, value) 322 - } 323 - } 324 - 325 - for (let i = oldElt.attributes.length - 1; i >= 0; i--) { 326 - const { name } = oldElt.attributes[i]! 327 - if (!newElt.hasAttribute(name) && !preserveAttrs.includes(name)) { 328 - oldElt.removeAttribute(name) 329 - } 330 - } 331 - 332 - if (!oldElt.isEqualNode(newElt)) { 333 - morphChildren(oldElt, newElt) 334 - } 335 - } 336 - 337 - if (type === 8 /* comment */ || type === 3 /* text */) { 338 - if (oldNode.nodeValue !== newNode.nodeValue) { 339 - oldNode.nodeValue = newNode.nodeValue 340 - } 341 - } 342 - 343 - return oldNode 344 - } 345 - 346 - // A bottom-up algorithm that populates a map of Element -> IdSet. 347 - // The ID set for a given element is the set of all IDs contained within its subtree. 348 - // As an optimization, we filter these IDs through the given list of persistent IDs, 349 - // because we don't need to bother considering IDed elements that won't be in the new content. 350 - const populateIdMapWithTree = (root: Element | ShadowRoot | null, elements: Iterable<Element>): void => { 351 - for (const elt of elements) { 352 - if (ctxPersistentIds.has(elt.id)) { 353 - let current: Element | null = elt 354 - // walk up the parent hierarchy of that element, adding the ID of element to the parent's ID set 355 - while (current && current !== root) { 356 - let idSet = ctxIdMap.get(current) 357 - // if the ID set doesn’t exist, create it and insert it in the map 358 - if (!idSet) { 359 - idSet = new Set() 360 - ctxIdMap.set(current, idSet) 361 - } 362 - idSet.add(elt.id) 363 - current = current.parentElement 364 - } 365 - } 366 - } 367 - }