Precise DOM morphing
morphing typescript dom
at main 453 lines 13 kB view raw
1import { Window } from "happy-dom" 2import type { Options } from "../src/morphlex" 3 4type BenchmarkCase = { 5 name: string 6 from: string 7 to: string 8 iterations?: number 9 weight?: number 10 options?: Options 11 setup?: (from: ChildNode, to: ChildNode) => void 12} 13 14type BenchmarkResult = { 15 name: string 16 iterations: number 17 mean: number 18 median: number 19 p95: number 20 min: number 21 max: number 22 opsPerSecond: number 23 total: number 24} 25 26type CliOptions = { 27 iterations: number 28 warmup: number 29 thorough: boolean 30 json: boolean 31 repeats: number 32} 33 34type BenchmarkSummary = { 35 weightedMedianMs: number 36 weightedP95Ms: number 37 totalMeasuredMs: number 38 trimmedMeanMs: number 39} 40 41const DEFAULT_ITERATIONS = 1500 42const DEFAULT_WARMUP = 250 43 44const window = new Window({ 45 url: "http://localhost", 46}) 47 48Object.assign(window, { 49 SyntaxError, 50}) 51 52const globals: Record<string, unknown> = { 53 window, 54 document: window.document, 55 Node: window.Node, 56 Element: window.Element, 57 NodeList: window.NodeList, 58 DOMParser: window.DOMParser, 59 DocumentFragment: window.DocumentFragment, 60 HTMLInputElement: window.HTMLInputElement, 61 HTMLOptionElement: window.HTMLOptionElement, 62 HTMLTextAreaElement: window.HTMLTextAreaElement, 63 performance: window.performance, 64} 65 66for (const [key, value] of Object.entries(globals)) { 67 Object.assign(globalThis, { [key]: value }) 68} 69 70const { morph } = await import("../src/morphlex") 71 72const 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 85function 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 97function 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 103function 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 119const benchmarkCases: Array<BenchmarkCase> = [ 120 { 121 name: "text-update", 122 from: "<div>Hello world</div>", 123 to: "<div>Goodbye world</div>", 124 weight: 1, 125 }, 126 { 127 name: "attribute-churn", 128 from: '<div class="a" data-mode="old" aria-hidden="true">Content</div>', 129 to: '<div class="b" data-mode="new" title="fresh">Content</div>', 130 weight: 1, 131 }, 132 { 133 name: "append-children", 134 from: "<ul><li>One</li></ul>", 135 to: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>", 136 weight: 1, 137 }, 138 { 139 name: "remove-children", 140 from: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>", 141 to: "<ul><li>One</li></ul>", 142 weight: 1, 143 }, 144 { 145 name: "reorder-with-ids-20", 146 from: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${i}">Item ${i}</li>`).join("")}</ul>`, 147 to: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${19 - i}">Item ${19 - i}</li>`).join("")}</ul>`, 148 weight: 2, 149 }, 150 { 151 name: "large-list-update-200", 152 from: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i}</p>`).join("")}</div>`, 153 to: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i} updated</p>`).join("")}</div>`, 154 iterations: 400, 155 weight: 3, 156 }, 157 { 158 name: "mixed-structure", 159 from: '<section><h1 id="title">Title</h1><p>Body</p><footer><a href="#">Link</a></footer></section>', 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, 221 }, 222] 223 224function parseOptions(argv: Array<string>): CliOptions { 225 let iterations = DEFAULT_ITERATIONS 226 let warmup = DEFAULT_WARMUP 227 let thorough = false 228 let json = false 229 let repeats = 1 230 231 for (let i = 0; i < argv.length; i++) { 232 const arg = argv[i] 233 if (arg === "--thorough") { 234 thorough = true 235 continue 236 } 237 238 if (arg === "--json") { 239 json = true 240 continue 241 } 242 243 if (arg === "--iterations") { 244 const value = Number(argv[i + 1]) 245 if (Number.isFinite(value) && value > 0) { 246 iterations = Math.floor(value) 247 i++ 248 } 249 continue 250 } 251 252 if (arg === "--warmup") { 253 const value = Number(argv[i + 1]) 254 if (Number.isFinite(value) && value >= 0) { 255 warmup = Math.floor(value) 256 i++ 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 } 267 } 268 } 269 270 if (thorough) { 271 iterations = Math.max(iterations, 5000) 272 warmup = Math.max(warmup, 1000) 273 } 274 275 return { iterations, warmup, thorough, json, repeats } 276} 277 278function 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 282} 283 284function createElement(html: string): ChildNode { 285 const template = document.createElement("template") 286 template.innerHTML = html 287 const node = template.content.firstChild 288 if (!node) throw new Error("Invalid benchmark fixture") 289 return node 290} 291 292function runCase(testCase: BenchmarkCase, options: CliOptions): BenchmarkResult { 293 const fromTemplate = createElement(testCase.from) 294 const toTemplate = createElement(testCase.to) 295 const iterations = testCase.iterations ?? options.iterations 296 const times: Array<number> = [] 297 const morphOptions = testCase.options ?? {} 298 299 for (let i = 0; i < options.warmup; i++) { 300 const from = fromTemplate.cloneNode(true) as ChildNode 301 const to = toTemplate.cloneNode(true) as ChildNode 302 testCase.setup?.(from, to) 303 morph(from, to, morphOptions) 304 } 305 306 for (let i = 0; i < iterations; i++) { 307 const from = fromTemplate.cloneNode(true) as ChildNode 308 const to = toTemplate.cloneNode(true) as ChildNode 309 testCase.setup?.(from, to) 310 const start = performance.now() 311 morph(from, to, morphOptions) 312 const end = performance.now() 313 times.push(end - start) 314 } 315 316 times.sort((a, b) => a - b) 317 let total = 0 318 for (let i = 0; i < times.length; i++) total += times[i]! 319 320 const mean = total / times.length 321 const median = times[Math.floor(times.length / 2)] ?? 0 322 const p95 = times[Math.floor(times.length * 0.95)] ?? 0 323 const min = times[0] ?? 0 324 const max = times[times.length - 1] ?? 0 325 const opsPerSecond = mean > 0 ? 1000 / mean : 0 326 327 return { 328 name: testCase.name, 329 iterations, 330 mean, 331 median, 332 p95, 333 min, 334 max, 335 opsPerSecond, 336 total, 337 } 338} 339 340function 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 377function 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 403function printTable(results: Array<BenchmarkResult>, options: CliOptions): void { 404 const rows = results.map((result) => { 405 const testCase = benchmarkCases.find((test) => test.name === result.name) 406 return { 407 benchmark: result.name, 408 weight: String(testCase?.weight ?? 1), 409 iterations: String(result.iterations), 410 mean: `${result.mean.toFixed(4)}ms`, 411 median: `${result.median.toFixed(4)}ms`, 412 p95: `${result.p95.toFixed(4)}ms`, 413 trimmedMean: `${Math.min(result.p95, result.mean).toFixed(4)}ms`, 414 ops: result.opsPerSecond.toFixed(1), 415 } 416 }) 417 418 const repeatLabel = options.repeats > 1 ? ` x${options.repeats} (median across runs)` : "" 419 console.log(`Morphlex benchmark${options.thorough ? " (thorough)" : ""}${repeatLabel}`) 420 console.table(rows) 421 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`) 427} 428 429function main(): void { 430 const options = parseOptions(process.argv.slice(2)) 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 } 439 440 runs.push(runResults) 441 } 442 443 const results = aggregateResults(runs) 444 445 if (options.json) { 446 console.log(JSON.stringify({ options, summary: summarizeResults(results), results }, null, 2)) 447 return 448 } 449 450 printTable(results, options) 451} 452 453main()