Precise DOM morphing
morphing typescript dom

Add repeatable benchmark harness

Introduce a scriptable benchmark runner with warmup, fixed fixtures, and thorough mode so performance changes can be measured consistently over time.

+234
+232
benchmark/run.ts
··· 1 + import { Window } from "happy-dom" 2 + 3 + type BenchmarkCase = { 4 + name: string 5 + from: string 6 + to: string 7 + iterations?: number 8 + } 9 + 10 + type BenchmarkResult = { 11 + name: string 12 + iterations: number 13 + mean: number 14 + median: number 15 + p95: number 16 + min: number 17 + max: number 18 + opsPerSecond: number 19 + total: number 20 + } 21 + 22 + type CliOptions = { 23 + iterations: number 24 + warmup: number 25 + thorough: boolean 26 + json: boolean 27 + } 28 + 29 + const DEFAULT_ITERATIONS = 1500 30 + const DEFAULT_WARMUP = 250 31 + 32 + const window = new Window({ 33 + url: "http://localhost", 34 + }) 35 + 36 + Object.assign(window, { 37 + SyntaxError, 38 + }) 39 + 40 + const globals: Record<string, unknown> = { 41 + window, 42 + document: window.document, 43 + Node: window.Node, 44 + Element: window.Element, 45 + NodeList: window.NodeList, 46 + DOMParser: window.DOMParser, 47 + DocumentFragment: window.DocumentFragment, 48 + HTMLInputElement: window.HTMLInputElement, 49 + HTMLOptionElement: window.HTMLOptionElement, 50 + HTMLTextAreaElement: window.HTMLTextAreaElement, 51 + performance: window.performance, 52 + } 53 + 54 + for (const [key, value] of Object.entries(globals)) { 55 + Object.assign(globalThis, { [key]: value }) 56 + } 57 + 58 + const { morph } = await import("../src/morphlex") 59 + 60 + const benchmarkCases: Array<BenchmarkCase> = [ 61 + { 62 + name: "text-update", 63 + from: "<div>Hello world</div>", 64 + to: "<div>Goodbye world</div>", 65 + }, 66 + { 67 + name: "attribute-churn", 68 + from: '<div class="a" data-mode="old" aria-hidden="true">Content</div>', 69 + to: '<div class="b" data-mode="new" title="fresh">Content</div>', 70 + }, 71 + { 72 + name: "append-children", 73 + from: "<ul><li>One</li></ul>", 74 + to: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>", 75 + }, 76 + { 77 + name: "remove-children", 78 + from: "<ul><li>One</li><li>Two</li><li>Three</li><li>Four</li></ul>", 79 + to: "<ul><li>One</li></ul>", 80 + }, 81 + { 82 + name: "reorder-with-ids-20", 83 + from: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${i}">Item ${i}</li>`).join("")}</ul>`, 84 + to: `<ul>${Array.from({ length: 20 }, (_, i) => `<li id="item-${19 - i}">Item ${19 - i}</li>`).join("")}</ul>`, 85 + }, 86 + { 87 + name: "large-list-update-200", 88 + from: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i}</p>`).join("")}</div>`, 89 + to: `<div>${Array.from({ length: 200 }, (_, i) => `<p id="row-${i}">Row ${i} updated</p>`).join("")}</div>`, 90 + iterations: 400, 91 + }, 92 + { 93 + name: "mixed-structure", 94 + from: '<section><h1 id="title">Title</h1><p>Body</p><footer><a href="#">Link</a></footer></section>', 95 + to: '<section><h1 id="title">Title 2</h1><p>Body updated</p><aside>Side</aside><footer><a href="/x">Link</a></footer></section>', 96 + }, 97 + ] 98 + 99 + function parseOptions(argv: Array<string>): CliOptions { 100 + let iterations = DEFAULT_ITERATIONS 101 + let warmup = DEFAULT_WARMUP 102 + let thorough = false 103 + let json = false 104 + 105 + for (let i = 0; i < argv.length; i++) { 106 + const arg = argv[i] 107 + if (arg === "--thorough") { 108 + thorough = true 109 + continue 110 + } 111 + 112 + if (arg === "--json") { 113 + json = true 114 + continue 115 + } 116 + 117 + if (arg === "--iterations") { 118 + const value = Number(argv[i + 1]) 119 + if (Number.isFinite(value) && value > 0) { 120 + iterations = Math.floor(value) 121 + i++ 122 + } 123 + continue 124 + } 125 + 126 + if (arg === "--warmup") { 127 + const value = Number(argv[i + 1]) 128 + if (Number.isFinite(value) && value >= 0) { 129 + warmup = Math.floor(value) 130 + i++ 131 + } 132 + } 133 + } 134 + 135 + if (thorough) { 136 + iterations = Math.max(iterations, 5000) 137 + warmup = Math.max(warmup, 1000) 138 + } 139 + 140 + return { iterations, warmup, thorough, json } 141 + } 142 + 143 + function createElement(html: string): ChildNode { 144 + const template = document.createElement("template") 145 + template.innerHTML = html 146 + const node = template.content.firstChild 147 + if (!node) throw new Error("Invalid benchmark fixture") 148 + return node 149 + } 150 + 151 + function runCase(testCase: BenchmarkCase, options: CliOptions): BenchmarkResult { 152 + const fromTemplate = createElement(testCase.from) 153 + const toTemplate = createElement(testCase.to) 154 + const iterations = testCase.iterations ?? options.iterations 155 + const times: Array<number> = [] 156 + 157 + for (let i = 0; i < options.warmup; i++) { 158 + const from = fromTemplate.cloneNode(true) as ChildNode 159 + const to = toTemplate.cloneNode(true) as ChildNode 160 + morph(from, to) 161 + } 162 + 163 + for (let i = 0; i < iterations; i++) { 164 + const from = fromTemplate.cloneNode(true) as ChildNode 165 + const to = toTemplate.cloneNode(true) as ChildNode 166 + const start = performance.now() 167 + morph(from, to) 168 + const end = performance.now() 169 + times.push(end - start) 170 + } 171 + 172 + times.sort((a, b) => a - b) 173 + let total = 0 174 + for (let i = 0; i < times.length; i++) total += times[i]! 175 + 176 + const mean = total / times.length 177 + const median = times[Math.floor(times.length / 2)] ?? 0 178 + const p95 = times[Math.floor(times.length * 0.95)] ?? 0 179 + const min = times[0] ?? 0 180 + const max = times[times.length - 1] ?? 0 181 + const opsPerSecond = mean > 0 ? 1000 / mean : 0 182 + 183 + return { 184 + name: testCase.name, 185 + iterations, 186 + mean, 187 + median, 188 + p95, 189 + min, 190 + max, 191 + opsPerSecond, 192 + total, 193 + } 194 + } 195 + 196 + function printTable(results: Array<BenchmarkResult>, options: CliOptions): void { 197 + const rows = results.map((result) => { 198 + return { 199 + benchmark: result.name, 200 + iterations: String(result.iterations), 201 + mean: `${result.mean.toFixed(4)}ms`, 202 + median: `${result.median.toFixed(4)}ms`, 203 + p95: `${result.p95.toFixed(4)}ms`, 204 + ops: result.opsPerSecond.toFixed(1), 205 + } 206 + }) 207 + 208 + console.log(`Morphlex benchmark${options.thorough ? " (thorough)" : ""}`) 209 + console.table(rows) 210 + 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`) 214 + } 215 + 216 + function main(): void { 217 + const options = parseOptions(process.argv.slice(2)) 218 + const results: Array<BenchmarkResult> = [] 219 + 220 + for (let i = 0; i < benchmarkCases.length; i++) { 221 + results.push(runCase(benchmarkCases[i]!, options)) 222 + } 223 + 224 + if (options.json) { 225 + console.log(JSON.stringify({ options, results }, null, 2)) 226 + return 227 + } 228 + 229 + printTable(results, options) 230 + } 231 + 232 + main()
+2
package.json
··· 17 17 }, 18 18 "scripts": { 19 19 "build": "bun run build.ts", 20 + "bench": "bun benchmark/run.ts", 21 + "bench:thorough": "bun benchmark/run.ts --thorough", 20 22 "lint": "bun run oxlint --type-aware", 21 23 "test": "vitest run", 22 24 "test:watch": "vitest",