Precise DOM morphing
morphing typescript dom

optimize exact-id matching buckets and expand benchmark coverage

+295 -22
+232 -11
benchmark/run.ts
··· 1 1 import { Window } from "happy-dom" 2 + import type { Options } from "../src/morphlex" 2 3 3 4 type BenchmarkCase = { 4 5 name: string 5 6 from: string 6 7 to: string 7 8 iterations?: number 9 + weight?: number 10 + options?: Options 11 + setup?: (from: ChildNode, to: ChildNode) => void 8 12 } 9 13 10 14 type BenchmarkResult = { ··· 24 28 warmup: number 25 29 thorough: boolean 26 30 json: boolean 31 + repeats: number 32 + } 33 + 34 + type BenchmarkSummary = { 35 + weightedMedianMs: number 36 + weightedP95Ms: number 37 + totalMeasuredMs: number 38 + trimmedMeanMs: number 27 39 } 28 40 29 41 const DEFAULT_ITERATIONS = 1500 ··· 57 69 58 70 const { morph } = await import("../src/morphlex") 59 71 72 + const noopOptions: Options = { 73 + beforeNodeVisited: () => true, 74 + afterNodeVisited: () => {}, 75 + beforeNodeAdded: () => true, 76 + afterNodeAdded: () => {}, 77 + beforeNodeRemoved: () => true, 78 + afterNodeRemoved: () => {}, 79 + beforeAttributeUpdated: () => true, 80 + afterAttributeUpdated: () => {}, 81 + beforeChildrenVisited: () => true, 82 + afterChildrenVisited: () => {}, 83 + } 84 + 85 + function buildIdRelatedCards(count: number, reverseOrder: boolean, updatedText: boolean): string { 86 + const indices = Array.from({ length: count }, (_, i) => i) 87 + if (reverseOrder) indices.reverse() 88 + 89 + return indices 90 + .map((i) => { 91 + const next = (i + 1) % count 92 + return `<article data-card="${i}"><h3 id="card-${i}">Card ${i}</h3><a href="#card-${next}" name="card-link-${i}">Next</a><p>${updatedText ? `Card ${i} updated` : `Card ${i}`}</p></article>` 93 + }) 94 + .join("") 95 + } 96 + 97 + function buildPartiallyReorderedList(count: number, shift: number): string { 98 + const indices = Array.from({ length: count }, (_, i) => i) 99 + const rotated = indices.slice(shift).concat(indices.slice(0, shift)) 100 + return rotated.map((i) => `<li id="row-${i}">Row ${i}</li>`).join("") 101 + } 102 + 103 + function buildDeepNestedIdTrees(count: number, depth: number, reverseOrder: boolean, updatedText: boolean): string { 104 + const indices = Array.from({ length: count }, (_, i) => i) 105 + if (reverseOrder) indices.reverse() 106 + 107 + return indices 108 + .map((i) => { 109 + const next = (i + 1) % count 110 + let nested = `<span id="deep-id-${i}">Node ${i}${updatedText ? " updated" : ""}</span><a href="#deep-id-${next}">Next</a>` 111 + for (let d = 0; d < depth; d++) { 112 + nested = `<div data-depth="${d}" data-key="${i}-${d}">${nested}</div>` 113 + } 114 + return `<article data-chain="${i}">${nested}</article>` 115 + }) 116 + .join("") 117 + } 118 + 60 119 const benchmarkCases: Array<BenchmarkCase> = [ 61 120 { 62 121 name: "text-update", 63 122 from: "<div>Hello world</div>", 64 123 to: "<div>Goodbye world</div>", 124 + weight: 1, 65 125 }, 66 126 { 67 127 name: "attribute-churn", 68 128 from: '<div class="a" data-mode="old" aria-hidden="true">Content</div>', 69 129 to: '<div class="b" data-mode="new" title="fresh">Content</div>', 130 + weight: 1, 70 131 }, 71 132 { 72 133 name: "append-children", 73 134 from: "<ul><li>One</li></ul>", 74 135 to: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>", 136 + weight: 1, 75 137 }, 76 138 { 77 139 name: "remove-children", 78 140 from: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>", 79 141 to: "<ul><li>One</li></ul>", 142 + weight: 1, 80 143 }, 81 144 { 82 145 name: "reorder-with-ids-20", 83 146 from: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${i}">Item ${i}</li>`).join("")}</ul>`, 84 147 to: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${19 - i}">Item ${19 - i}</li>`).join("")}</ul>`, 148 + weight: 2, 85 149 }, 86 150 { 87 151 name: "large-list-update-200", 88 152 from: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i}</p>`).join("")}</div>`, 89 153 to: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i} updated</p>`).join("")}</div>`, 90 154 iterations: 400, 155 + weight: 3, 91 156 }, 92 157 { 93 158 name: "mixed-structure", 94 159 from: '<section><h1 id="title">Title</h1><p>Body</p><footer><a href="#">Link</a></footer></section>', 95 160 to: '<section><h1 id="title">Title 2</h1><p>Body updated</p><aside>Side</aside><footer><a href="/x">Link</a></footer></section>', 161 + weight: 2, 162 + }, 163 + { 164 + name: "partial-reorder-with-ids-100", 165 + from: `<ul>${buildPartiallyReorderedList(100, 0)}</ul>`, 166 + to: `<ul>${buildPartiallyReorderedList(100, 30)}</ul>`, 167 + iterations: 350, 168 + weight: 3, 169 + }, 170 + { 171 + name: "idset-matching-related-cards-60", 172 + from: `<section>${buildIdRelatedCards(60, false, false)}</section>`, 173 + to: `<section>${buildIdRelatedCards(60, true, true)}</section>`, 174 + iterations: 350, 175 + weight: 3, 176 + }, 177 + { 178 + name: "deep-id-ancestry-40x8", 179 + from: `<section>${buildDeepNestedIdTrees(40, 8, false, false)}</section>`, 180 + to: `<section>${buildDeepNestedIdTrees(40, 8, true, true)}</section>`, 181 + iterations: 300, 182 + weight: 3, 183 + }, 184 + { 185 + name: "dirty-form-text-inputs-60", 186 + from: `<form>${Array.from({ length: 60 }, (_, i) => `<input name="field-${i}" value="value-${i}">`).join("")}</form>`, 187 + to: `<form>${Array.from({ length: 60 }, (_, i) => `<input name="field-${i}" value="new-${i}" data-next="1">`).join("")}</form>`, 188 + iterations: 500, 189 + weight: 1, 190 + setup: (from) => { 191 + const textInputs = (from as Element).querySelectorAll("input[name^='field-']") 192 + for (let i = 0; i < textInputs.length; i++) { 193 + const input = textInputs[i] as HTMLInputElement 194 + input.value = `${input.value}-dirty` 195 + } 196 + }, 197 + }, 198 + { 199 + name: "dirty-form-checkboxes-60", 200 + from: `<form>${Array.from({ length: 60 }, (_, i) => `<input type="checkbox" name="check-${i}" ${i % 3 === 0 ? "checked" : ""}>`).join("")}</form>`, 201 + to: `<form>${Array.from({ length: 60 }, (_, i) => `<input type="checkbox" name="check-${i}" ${i % 5 === 0 ? "checked" : ""}>`).join("")}</form>`, 202 + iterations: 500, 203 + weight: 1, 204 + setup: (from) => { 205 + const checkboxes = (from as Element).querySelectorAll("input[type='checkbox']") 206 + 207 + for (let i = 0; i < checkboxes.length; i++) { 208 + if (i % 4 === 0) { 209 + const checkbox = checkboxes[i] as HTMLInputElement 210 + checkbox.checked = !checkbox.checked 211 + } 212 + } 213 + }, 214 + }, 215 + { 216 + name: "hooks-mixed-structure", 217 + from: '<section><h1 id="title">Title</h1><p>Body</p><footer><a href="#">Link</a></footer></section>', 218 + to: '<section><h1 id="title">Title 2</h1><p>Body updated</p><aside>Side</aside><footer><a href="/x">Link</a></footer></section>', 219 + options: noopOptions, 220 + weight: 2, 96 221 }, 97 222 ] 98 223 ··· 101 226 let warmup = DEFAULT_WARMUP 102 227 let thorough = false 103 228 let json = false 229 + let repeats = 1 104 230 105 231 for (let i = 0; i < argv.length; i++) { 106 232 const arg = argv[i] ··· 129 255 warmup = Math.floor(value) 130 256 i++ 131 257 } 258 + continue 259 + } 260 + 261 + if (arg === "--repeats") { 262 + const value = Number(argv[i + 1]) 263 + if (Number.isFinite(value) && value > 0) { 264 + repeats = Math.floor(value) 265 + i++ 266 + } 132 267 } 133 268 } 134 269 ··· 137 272 warmup = Math.max(warmup, 1000) 138 273 } 139 274 140 - return { iterations, warmup, thorough, json } 275 + return { iterations, warmup, thorough, json, repeats } 276 + } 277 + 278 + function median(numbers: Array<number>): number { 279 + if (numbers.length === 0) return 0 280 + const sorted = [...numbers].sort((a, b) => a - b) 281 + return sorted[Math.floor(sorted.length / 2)] ?? 0 141 282 } 142 283 143 284 function createElement(html: string): ChildNode { ··· 153 294 const toTemplate = createElement(testCase.to) 154 295 const iterations = testCase.iterations ?? options.iterations 155 296 const times: Array<number> = [] 297 + const morphOptions = testCase.options ?? {} 156 298 157 299 for (let i = 0; i < options.warmup; i++) { 158 300 const from = fromTemplate.cloneNode(true) as ChildNode 159 301 const to = toTemplate.cloneNode(true) as ChildNode 160 - morph(from, to) 302 + testCase.setup?.(from, to) 303 + morph(from, to, morphOptions) 161 304 } 162 305 163 306 for (let i = 0; i < iterations; i++) { 164 307 const from = fromTemplate.cloneNode(true) as ChildNode 165 308 const to = toTemplate.cloneNode(true) as ChildNode 309 + testCase.setup?.(from, to) 166 310 const start = performance.now() 167 - morph(from, to) 311 + morph(from, to, morphOptions) 168 312 const end = performance.now() 169 313 times.push(end - start) 170 314 } ··· 193 337 } 194 338 } 195 339 340 + function summarizeResults(results: Array<BenchmarkResult>): BenchmarkSummary { 341 + let totalWeight = 0 342 + let weightedMedianMs = 0 343 + let weightedP95Ms = 0 344 + let totalMeasuredMs = 0 345 + let weightedTrimmedMeanMs = 0 346 + 347 + for (let i = 0; i < results.length; i++) { 348 + const result = results[i]! 349 + const testCase = benchmarkCases[i]! 350 + const weight = testCase.weight ?? 1 351 + const trimmedMean = Math.min(result.p95, result.mean) 352 + 353 + totalWeight += weight 354 + weightedMedianMs += result.median * weight 355 + weightedP95Ms += result.p95 * weight 356 + weightedTrimmedMeanMs += trimmedMean * weight 357 + totalMeasuredMs += result.total 358 + } 359 + 360 + if (totalWeight === 0) { 361 + return { 362 + weightedMedianMs: 0, 363 + weightedP95Ms: 0, 364 + totalMeasuredMs, 365 + trimmedMeanMs: 0, 366 + } 367 + } 368 + 369 + return { 370 + weightedMedianMs: weightedMedianMs / totalWeight, 371 + weightedP95Ms: weightedP95Ms / totalWeight, 372 + totalMeasuredMs, 373 + trimmedMeanMs: weightedTrimmedMeanMs / totalWeight, 374 + } 375 + } 376 + 377 + function aggregateResults(runs: Array<Array<BenchmarkResult>>): Array<BenchmarkResult> { 378 + if (runs.length === 0) return [] 379 + 380 + const caseCount = runs[0]!.length 381 + const aggregated: Array<BenchmarkResult> = [] 382 + 383 + for (let caseIndex = 0; caseIndex < caseCount; caseIndex++) { 384 + const samples = runs.map((run) => run[caseIndex]!) 385 + const first = samples[0]! 386 + 387 + aggregated.push({ 388 + name: first.name, 389 + iterations: first.iterations, 390 + mean: median(samples.map((sample) => sample.mean)), 391 + median: median(samples.map((sample) => sample.median)), 392 + p95: median(samples.map((sample) => sample.p95)), 393 + min: median(samples.map((sample) => sample.min)), 394 + max: median(samples.map((sample) => sample.max)), 395 + opsPerSecond: median(samples.map((sample) => sample.opsPerSecond)), 396 + total: median(samples.map((sample) => sample.total)), 397 + }) 398 + } 399 + 400 + return aggregated 401 + } 402 + 196 403 function printTable(results: Array<BenchmarkResult>, options: CliOptions): void { 197 404 const rows = results.map((result) => { 405 + const testCase = benchmarkCases.find((test) => test.name === result.name) 198 406 return { 199 407 benchmark: result.name, 408 + weight: String(testCase?.weight ?? 1), 200 409 iterations: String(result.iterations), 201 410 mean: `${result.mean.toFixed(4)}ms`, 202 411 median: `${result.median.toFixed(4)}ms`, 203 412 p95: `${result.p95.toFixed(4)}ms`, 413 + trimmedMean: `${Math.min(result.p95, result.mean).toFixed(4)}ms`, 204 414 ops: result.opsPerSecond.toFixed(1), 205 415 } 206 416 }) 207 417 208 - console.log(`Morphlex benchmark${options.thorough ? " (thorough)" : ""}`) 418 + const repeatLabel = options.repeats > 1 ? ` x${options.repeats} (median across runs)` : "" 419 + console.log(`Morphlex benchmark${options.thorough ? " (thorough)" : ""}${repeatLabel}`) 209 420 console.table(rows) 210 421 211 - let totalMs = 0 212 - for (let i = 0; i < results.length; i++) totalMs += results[i]!.total 213 - console.log(`Total measured time: ${totalMs.toFixed(2)}ms`) 422 + const summary = summarizeResults(results) 423 + console.log(`Weighted median: ${summary.weightedMedianMs.toFixed(4)}ms`) 424 + console.log(`Weighted p95: ${summary.weightedP95Ms.toFixed(4)}ms`) 425 + console.log(`Weighted trimmed mean: ${summary.trimmedMeanMs.toFixed(4)}ms`) 426 + console.log(`Total measured time: ${summary.totalMeasuredMs.toFixed(2)}ms`) 214 427 } 215 428 216 429 function main(): void { 217 430 const options = parseOptions(process.argv.slice(2)) 218 - const results: Array<BenchmarkResult> = [] 431 + const runs: Array<Array<BenchmarkResult>> = [] 432 + 433 + for (let repeat = 0; repeat < options.repeats; repeat++) { 434 + const runResults: Array<BenchmarkResult> = [] 435 + 436 + for (let i = 0; i < benchmarkCases.length; i++) { 437 + runResults.push(runCase(benchmarkCases[i]!, options)) 438 + } 219 439 220 - for (let i = 0; i < benchmarkCases.length; i++) { 221 - results.push(runCase(benchmarkCases[i]!, options)) 440 + runs.push(runResults) 222 441 } 223 442 443 + const results = aggregateResults(runs) 444 + 224 445 if (options.json) { 225 - console.log(JSON.stringify({ options, results }, null, 2)) 446 + console.log(JSON.stringify({ options, summary: summarizeResults(results), results }, null, 2)) 226 447 return 227 448 } 228 449
+1
package.json
··· 19 19 "build": "bun run build.ts", 20 20 "bench": "bun benchmark/run.ts", 21 21 "bench:thorough": "bun benchmark/run.ts --thorough", 22 + "bench:decision": "bun benchmark/run.ts --repeats 3 --thorough", 22 23 "lint": "bun run oxlint --type-aware", 23 24 "test": "vitest run", 24 25 "test:watch": "vitest",
+27 -11
src/morphlex.ts
··· 29 29 30 30 type IdSetMap = WeakMap<Node, Set<string>> 31 31 type IdArrayMap = WeakMap<Node, Array<string>> 32 + type CandidateIdBucket = number | Array<number> 32 33 33 34 const queuedActiveElementTargets: WeakMap<Element, ChildNode> = new WeakMap() 34 35 const queuedActiveElementOptions: WeakMap<Element, Options> = new WeakMap() ··· 459 460 const candidateNodeIndices: Array<number> = [] 460 461 const candidateElementIndices: Array<number> = [] 461 462 const candidateElementWithIdIndices: Array<number> = [] 462 - const candidateElementIndicesById: Map<string, Array<number>> = new Map() 463 + const candidateElementIndicesById: Map<string, CandidateIdBucket> = new Map() 463 464 const unmatchedNodeIndices: Array<number> = [] 464 465 const unmatchedElementIndices: Array<number> = [] 465 466 const whitespaceNodeIndices: Array<number> = [] ··· 490 491 candidateElementWithIdActive[i] = 1 491 492 candidateElementWithIdIndices.push(i) 492 493 493 - const existingIndices = candidateElementIndicesById.get(candidateId) 494 - if (existingIndices) { 495 - existingIndices.push(i) 494 + const existingBucket = candidateElementIndicesById.get(candidateId) 495 + if (existingBucket === undefined) { 496 + candidateElementIndicesById.set(candidateId, i) 497 + } else if (Array.isArray(existingBucket)) { 498 + existingBucket.push(i) 496 499 } else { 497 - candidateElementIndicesById.set(candidateId, [i]) 500 + candidateElementIndicesById.set(candidateId, [existingBucket, i]) 498 501 } 499 502 } else { 500 503 candidateElementActive[i] = 1 ··· 560 563 561 564 if (id === "") continue 562 565 563 - const candidateIndices = candidateElementIndicesById.get(id) 564 - if (!candidateIndices) continue 566 + const candidateBucket = candidateElementIndicesById.get(id) 567 + if (candidateBucket === undefined) continue 568 + 569 + if (Array.isArray(candidateBucket)) { 570 + for (let c = 0; c < candidateBucket.length; c++) { 571 + const candidateIndex = candidateBucket[c]! 572 + if (!candidateElementWithIdActive[candidateIndex]) continue 573 + const candidate = fromChildNodes[candidateIndex] as Element 565 574 566 - for (let c = 0; c < candidateIndices.length; c++) { 567 - const candidateIndex = candidateIndices[c]! 575 + if (localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && id === candidate.id) { 576 + matches[unmatchedIndex] = candidateIndex 577 + op[unmatchedIndex] = Operation.SameElement 578 + candidateElementWithIdActive[candidateIndex] = 0 579 + unmatchedElementActive[unmatchedIndex] = 0 580 + break 581 + } 582 + } 583 + } else { 584 + const candidateIndex = candidateBucket 568 585 if (!candidateElementWithIdActive[candidateIndex]) continue 569 - const candidate = fromChildNodes[candidateIndex] as Element 570 586 587 + const candidate = fromChildNodes[candidateIndex] as Element 571 588 if (localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && id === candidate.id) { 572 589 matches[unmatchedIndex] = candidateIndex 573 590 op[unmatchedIndex] = Operation.SameElement 574 591 candidateElementWithIdActive[candidateIndex] = 0 575 592 unmatchedElementActive[unmatchedIndex] = 0 576 - break 577 593 } 578 594 } 579 595 }
+23
test/new/reordering.browser.test.ts
··· 144 144 expect(children[1]?.className).toBe("unchanged") 145 145 expect(children[1]?.textContent).toBe("A") 146 146 }) 147 + 148 + test("handles duplicate ids while preserving target order", () => { 149 + const from = dom(` 150 + <div> 151 + <span id="x">one</span> 152 + <span id="x">two</span> 153 + </div> 154 + `) 155 + 156 + const to = dom(` 157 + <div> 158 + <span id="x">two</span> 159 + <span id="x">one</span> 160 + </div> 161 + `) 162 + 163 + morph(from, to) 164 + 165 + const children = Array.from(from.children) as HTMLSpanElement[] 166 + expect(children.length).toBe(2) 167 + expect(children[0]?.textContent).toBe("two") 168 + expect(children[1]?.textContent).toBe("one") 169 + }) 147 170 })
+12
todo.md
··· 1 + - [ ] Precompute `to` element matching hints (`id`, `name`, `href`, `src`) in `visitChildNodes` to reduce repeated `getAttribute` calls. 2 + - [ ] Split candidate pools by `localName` once (small `Map<string, number[]>`) so matching passes scan fewer candidates. 3 + - [ ] Cache `candidate` element attribute hints (`name`/`href`/`src`) for heuristic matching instead of reading per comparison. 4 + - [ ] Add an early fast path for identical child list lengths + stable id order to skip some matching passes. 5 + - [ ] Reuse scratch arrays/typed arrays in `Morph` (size-grow strategy) to reduce per-call allocations in hot paths. 6 + - [ ] Reduce `nodeListToArray` churn by using a shared reusable buffer for child snapshots when safe. 7 + - [ ] Try replacing `Map<string, number[]>` for exact-id matches with a compact struct for the common single-index case. 8 + - [ ] Add a narrow fast path for text-only child updates before running full child diff. 9 + - [x] Benchmark preserving dirty form controls separately for text inputs vs checkboxes to target real bottlenecks. 10 + - [ ] Consider skipping LIS when match order is already monotonic (cheap monotonicity check first). 11 + - [x] Add a benchmark case for deep nested id-set trees (many ancestors per id) to pressure `#mapIdSets`/`#mapIdArrays`. 12 + - [x] Add a benchmark flag preset (`--repeats 3 --thorough`) helper script to standardize decision runs.